Compare commits

..
19 Commits
Author SHA1 Message Date
shihaam a4820aab18 bruh....blame claude ._. wtf man
Build and deploy / Build and Push Docker Images (push) Successful in 3m19s
2025-12-15 01:04:50 +05:00
shihaam ebc7f9cb84 fix auto zoom to do it only on new map selections
Build and deploy / Build and Push Docker Images (push) Successful in 3m9s
2025-12-15 00:25:19 +05:00
shihaam 4f0ce8744e fix frontend build
Build and deploy / Build and Push Docker Images (push) Successful in 2m28s
2025-12-14 23:22:32 +05:00
shihaam e36e5e8fc5 google map as default and auto zoom
Build and deploy / Build and Push Docker Images (push) Failing after 39s
2025-12-14 23:19:20 +05:00
shihaam 57adb221f9 fix issue reconnecting cable after removing old cable: error ports full fix
Build and deploy / Build and Push Docker Images (push) Successful in 2m17s
2025-12-14 21:30:40 +05:00
shihaam 7e4bd35f3a snap radius to 2.5 meters and added indicator for snap 2025-12-14 21:25:59 +05:00
shihaam d85849debe auto snap changed to 1 meter
Build and deploy / Build and Push Docker Images (push) Successful in 2m25s
2025-12-14 21:10:52 +05:00
shihaam 7eaf1b3b86 add other device 2025-12-14 21:08:38 +05:00
shihaam 61ba40c7d2 update docs
Build and deploy / Build and Push Docker Images (push) Successful in 2m30s
2025-12-13 19:41:36 +05:00
shihaam 52889def40 manage users cli
Build and deploy / Build and Push Docker Images (push) Successful in 2m35s
2025-12-13 19:28:50 +05:00
shihaam 52b976c452 fix pin image 404
Build and deploy / Build and Push Docker Images (push) Successful in 3m19s
2025-12-13 19:08:43 +05:00
shihaam 0e436aac26 fix share to users UI
Build and deploy / Build and Push Docker Images (push) Successful in 3m22s
2025-12-13 16:48:03 +05:00
shihaam 42bca3d114 fix hardcoded domain url to by current domain
Build and deploy / Build and Push Docker Images (push) Successful in 3m44s
2025-12-13 16:34:52 +05:00
shihaam cf0a39c43b update docs 2025-12-13 16:04:04 +05:00
shihaam 06346b5fcd increase upload to 8MB 2025-12-13 16:03:25 +05:00
shihaam f92e14348f attempt at writing docs :D
Build and deploy / Build and Push Docker Images (push) Has been cancelled
2025-12-13 15:38:14 +05:00
shihaam 872ebc1971 ci auto build and deploy
Build and deploy / Build and Push Docker Images (push) Has been cancelled
2025-12-13 15:35:08 +05:00
shihaam 3934cbf3ae fix typescrypt build shit 2025-12-13 15:29:17 +05:00
shihaam 36f7ce8e5d add docker prod build files 2025-12-13 15:28:55 +05:00
30 changed files with 879 additions and 124 deletions
+1
View File
@@ -4,6 +4,7 @@ server {
server_name _;
access_log /dev/stdout;
error_log /dev/stdout info;
client_max_body_size 8M;
location /api/ {
proxy_pass http://python:8000/api/;
+19
View File
@@ -0,0 +1,19 @@
FROM node:20-slim AS frontendbuilder
WORKDIR /tmp/frontendbuilder
COPY public/ .
RUN npm ci && npm run build
FROM python:3.11.4-slim-buster
WORKDIR /var/www/html/
COPY . .
RUN pip install -r requirements.txt
COPY --from=frontendbuilder /tmp/frontendbuilder/dist/ /var/www/html/public
VOLUME /var/www/html/public
CMD uvicorn app.main:app --host 0.0.0.0 --port 8000
+13
View File
@@ -0,0 +1,13 @@
services:
app:
build:
context: ../../
dockerfile: .build/prod/app.Dockerfile
hostname: mapmaker
image: git.shihaam.dev/sarlink/mapmaker/api
nginx:
build:
context: .
dockerfile: ./nginx.Dockerfile
hostname: mapmaker-nginx
image: git.shihaam.dev/sarlink/mapmaker/nginx
+11 -13
View File
@@ -4,9 +4,10 @@ server {
server_name _;
access_log /dev/stdout;
error_log /dev/stdout info;
client_max_body_size 8M;
location /api/ {
proxy_pass http://api:8000/api/;
proxy_pass http://mapmaker:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -15,7 +16,7 @@ server {
}
location /ws/ {
proxy_pass http://api:8000/ws/;
proxy_pass http://mapmaker:8000/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@@ -23,22 +24,19 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket timeout settings
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_connect_timeout 60s;
}
location / {
# In development, proxy to Vite dev server
proxy_pass http://node:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
root /var/www/html/public;
try_files $uri $uri/ /index.html;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}
-5
View File
@@ -1,5 +0,0 @@
FROM node:24-bookworm-slim
WORKDIR /var/www/html/public
CMD npm run dev -- --host 0.0.0.0
-7
View File
@@ -1,7 +0,0 @@
FROM python:3.11.4-slim-buster
WORKDIR /var/www/html/
COPY requirements.txt .
RUN pip install -r requirements.txt
+45
View File
@@ -0,0 +1,45 @@
name: Build and deploy
on:
push:
branches:
- main
- master
pull_request:
branches:
- main
- master
jobs:
docker:
name: Build and Push Docker Images
runs-on: builder
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Login to Docker Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
registry: ${{ vars.DOCKER_REGISTRY_URL }}
username: ${{ vars.DOCKER_REGISTRY_USER }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: Build and push docker images
working-directory: .build/prod
run: docker compose --progress plain build --push
- name: Deploy production
if: github.event_name != 'pull_request'
run: |
ssh root@10.0.1.36 -t "cd /mnt && \
docker compose --progress plain pull && \
docker compose --progress plain down && \
docker compose --progress plain up -d"
- name: Clean up dangling images
if: github.event_name != 'pull_request'
run: ssh root@10.0.1.36 -t "docker image prune -f"
+121
View File
@@ -0,0 +1,121 @@
# ISP Wiremap Application
A web-based mapping application for managing ISP network infrastructure with real-time collaboration features.
## Tech Stack
- **Backend:** FastAPI with Python 3, PostgreSQL 15 with PostGIS
- **Frontend:** React 19 with TypeScript, Vite, Leaflet for mapping
- **Authentication:** JWT tokens with bcrypt password hashing
- **Real-time:** WebSockets for collaborative editing
## Getting Started
### Run Migrations
```bash
docker compose exec python alembic upgrade head
```
### Start the Application
```bash
docker compose up -d
```
The application will be available at `http://localhost:8000`
## User Management
### Create Admin User
Interactively create a new admin user with username, email, and password:
```bash
docker compose exec python python scripts/create_admin.py
```
### List Users
View all users in the system with their details (username, email, admin status, creation date):
```bash
docker compose exec python python scripts/manage_users.py list
```
### Reset User Password
Reset a user's password by providing their username, email, or user ID:
```bash
docker compose exec python python scripts/manage_users.py reset-password <username|email|user_id>
```
### Delete User
Delete a user from the system (requires confirmation, affects all their maps and shares):
```bash
docker compose exec python python scripts/manage_users.py delete <username|email|user_id>
```
### Toggle Admin Status
Grant or revoke admin privileges for a user:
```bash
docker compose exec python python scripts/manage_users.py toggle-admin <username|email|user_id>
```
## Production Deployment
Example Docker Compose configuration for production:
```yaml
services:
app:
hostname: mapmaker
image: git.shihaam.dev/sarlink/mapmaker/api
env_file: .env
volumes:
- ./storage/images:/var/www/html/storage/images
depends_on:
- postgres
nginx:
hostname: mapmaker-nginx
image: git.shihaam.dev/sarlink/mapmaker/nginx
volumes_from:
- app
ports:
- 8000:80
depends_on:
- app
postgres:
image: postgis/postgis:15-3.3
hostname: database
restart: always
environment:
POSTGRES_USER: mapmaker
POSTGRES_PASSWORD: mapmaker
POSTGRES_DB: mapmaker
volumes:
- ./storage/database:/var/lib/postgresql/data
# ports:
# - "5432:5432"
```
## Environment Configuration
Copy `.env.example` to `.env` and configure the required variables:
- `SECRET_KEY` - JWT secret key for token signing
- `DATABASE_URL` - PostgreSQL connection string
- `ALLOW_REGISTRATION` - Enable/disable public user registration (default: false)
- `ACCESS_TOKEN_EXPIRE_MINUTES` - JWT access token lifetime (default: 30)
- `REFRESH_TOKEN_EXPIRE_DAYS` - JWT refresh token lifetime (default: 7)
## License
GPL v2
+4 -1
View File
@@ -16,15 +16,18 @@ class MapItem(Base):
- switch: Network switch
- indoor_ap: Indoor access point
- outdoor_ap: Outdoor access point
- other_device: Other device (1 ethernet port)
- info: Information marker
Geometry:
- Point for devices (switches, APs)
- Point for devices (switches, APs, other devices, info markers)
- 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
- For info markers: name, notes, image (optional)
"""
__tablename__ = "map_items"
+2 -1
View File
@@ -10,7 +10,8 @@ from app.models.user import User
router = APIRouter(prefix="/api/uploads", tags=["uploads"])
# Storage directory for uploaded images
STORAGE_DIR = Path("/home/shihaam/git/sarlink/mapmaker/storage/images")
# Use relative path from project root to work in both dev and production
STORAGE_DIR = Path(__file__).resolve().parent.parent.parent / "storage" / "images"
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
# Allowed image types
+1 -1
View File
@@ -6,7 +6,7 @@ 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")
type: str = Field(..., description="Item type: cable, wireless_mesh, switch, indoor_ap, outdoor_ap, other_device, info")
geometry: Dict[str, Any] = Field(..., description="GeoJSON geometry (Point or LineString)")
properties: Dict[str, Any] = Field(default_factory=dict, description="Item-specific properties")
+2 -1
View File
@@ -1,4 +1,5 @@
import { useState, FormEvent } from 'react';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { useUIStore } from '../../stores/uiStore';
+2 -1
View File
@@ -1,4 +1,5 @@
import { useState, FormEvent } from 'react';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { useUIStore } from '../../stores/uiStore';
+1 -1
View File
@@ -1,4 +1,4 @@
import { ReactNode } from 'react';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { useUIStore } from '../../stores/uiStore';
+130 -52
View File
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { useMapEvents, Polyline, Marker } from 'react-leaflet';
import L from 'leaflet';
import { useMapEvents, Polyline, Marker, Circle } from 'react-leaflet';
import { useDrawingStore } from '../../stores/drawingStore';
import { useUIStore } from '../../stores/uiStore';
import { mapItemService } from '../../services/mapItemService';
@@ -9,19 +8,22 @@ import { CABLE_COLORS, type CableType } from '../../types/mapItem';
interface DrawingHandlerProps {
mapId: string;
onItemCreated: () => void;
refreshTrigger?: number;
}
export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
const { activeTool, isDrawing, drawingPoints, setIsDrawing, addDrawingPoint, resetDrawing, setActiveTool } =
export function DrawingHandler({ mapId, onItemCreated, refreshTrigger }: DrawingHandlerProps) {
const { activeTool, isDrawing, drawingPoints, setIsDrawing, addDrawingPoint, resetDrawing } =
useDrawingStore();
const { showToast } = useUIStore();
const [cursorPosition, setCursorPosition] = useState<[number, number] | null>(null);
const [allItems, setAllItems] = useState<any[]>([]);
const [startDeviceId, setStartDeviceId] = useState<string | null>(null);
const [endDeviceId, setEndDeviceId] = useState<string | null>(null);
const [nearbyDevice, setNearbyDevice] = useState<any | null>(null);
const [nearbyFullDevice, setNearbyFullDevice] = useState<any | null>(null);
const isCableTool = ['fiber', 'cat6', 'cat6_poe'].includes(activeTool);
const isDeviceTool = ['switch', 'indoor_ap', 'outdoor_ap', 'info'].includes(activeTool);
const isDeviceTool = ['switch', 'indoor_ap', 'outdoor_ap', 'other_device', 'info'].includes(activeTool);
const isWirelessTool = activeTool === 'wireless_mesh';
// Load all map items for snapping
@@ -35,12 +37,12 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
}
};
loadItems();
}, [mapId]);
}, [mapId, refreshTrigger]);
// Find nearby device for snapping (exclude info markers)
const findNearbyDevice = (lat: number, lng: number, radiusMeters = 5): any | null => {
const findNearbyDevice = (lat: number, lng: number, radiusMeters = 2.5): any | null => {
const devices = allItems.filter(item =>
['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type) &&
['switch', 'indoor_ap', 'outdoor_ap', 'other_device'].includes(item.type) &&
item.geometry.type === 'Point'
);
@@ -79,6 +81,10 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
let { lat, lng } = e.latlng;
let clickedDevice = null;
// Clear snap indicator on click
setNearbyDevice(null);
setNearbyFullDevice(null);
// Check for nearby device when drawing cables
if (isCableTool && map) {
clickedDevice = findNearbyDevice(lat, lng);
@@ -117,7 +123,7 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
// Only add port_count and connections if it's not an info marker
if (activeTool !== 'info') {
properties.port_count = activeTool === 'switch' ? 5 : activeTool === 'outdoor_ap' ? 1 : 4;
properties.port_count = activeTool === 'switch' ? 5 : (activeTool === 'outdoor_ap' || activeTool === 'other_device') ? 1 : 4;
properties.connections = [];
}
@@ -152,7 +158,7 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
// Wireless mesh - connect AP to AP only
if (isWirelessTool) {
// Must click on an AP
const ap = findNearbyDevice(lat, lng, 5);
const ap = findNearbyDevice(lat, lng, 2.5);
if (!ap || !['indoor_ap', 'outdoor_ap'].includes(ap.type)) {
showToast('Wireless mesh can only connect between Access Points. Please click on an AP.', 'error');
return;
@@ -192,6 +198,40 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
if (isDrawing && (isCableTool || isWirelessTool)) {
setCursorPosition([e.latlng.lat, e.latlng.lng]);
}
// Check for nearby device to show snap indicator
if ((isCableTool || isWirelessTool) && map) {
const nearby = findNearbyDevice(e.latlng.lat, e.latlng.lng);
// For wireless mesh, only show indicator for APs
if (isWirelessTool) {
if (nearby && ['indoor_ap', 'outdoor_ap'].includes(nearby.type)) {
setNearbyDevice(nearby);
setNearbyFullDevice(null);
} else {
setNearbyDevice(null);
setNearbyFullDevice(null);
}
} else if (isCableTool) {
// For cables, check port availability
if (nearby) {
if (hasAvailablePorts(nearby)) {
setNearbyDevice(nearby);
setNearbyFullDevice(null);
} else {
// Device has full ports - show red indicator
setNearbyDevice(null);
setNearbyFullDevice(nearby);
}
} else {
setNearbyDevice(null);
setNearbyFullDevice(null);
}
}
} else {
setNearbyDevice(null);
setNearbyFullDevice(null);
}
},
contextmenu: async (e) => {
@@ -215,6 +255,8 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
if (e.key === 'Escape' && isDrawing) {
resetDrawing();
setCursorPosition(null);
setNearbyDevice(null);
setNearbyFullDevice(null);
}
};
@@ -222,6 +264,12 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isDrawing, resetDrawing]);
// Clear nearby device indicator when tool changes
useEffect(() => {
setNearbyDevice(null);
setNearbyFullDevice(null);
}, [activeTool]);
const finishCable = async () => {
if (drawingPoints.length < 2) return;
@@ -245,6 +293,8 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
setCursorPosition(null);
setStartDeviceId(null);
setEndDeviceId(null);
setNearbyDevice(null);
setNearbyFullDevice(null);
// Reload items
const items = await mapItemService.getMapItems(mapId);
setAllItems(items);
@@ -284,6 +334,8 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
setCursorPosition(null);
setStartDeviceId(null);
setEndDeviceId(null);
setNearbyDevice(null);
setNearbyFullDevice(null);
// Reload items
const items = await mapItemService.getMapItems(mapId);
setAllItems(items);
@@ -294,55 +346,81 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
setCursorPosition(null);
setStartDeviceId(null);
setEndDeviceId(null);
setNearbyDevice(null);
setNearbyFullDevice(null);
}
};
// Render drawing preview
if (isDrawing && drawingPoints.length > 0) {
const color = isCableTool
? CABLE_COLORS[activeTool as CableType]
: isWirelessTool
? '#10B981'
: '#6B7280';
// Render drawing preview and snap indicator
const color = isCableTool
? CABLE_COLORS[activeTool as CableType]
: isWirelessTool
? '#10B981'
: '#6B7280';
const dashArray = isWirelessTool ? '10, 10' : undefined;
const dashArray = isWirelessTool ? '10, 10' : undefined;
// Create preview line from last point to cursor
const previewPositions = cursorPosition
? [...drawingPoints, cursorPosition]
: drawingPoints;
return (
<>
{/* Green snap indicator - show when hovering near a device with available ports */}
{nearbyDevice && nearbyDevice.geometry.type === 'Point' && (
<Circle
center={[nearbyDevice.geometry.coordinates[1], nearbyDevice.geometry.coordinates[0]]}
radius={3}
pathOptions={{
color: '#10B981',
fillColor: '#10B981',
fillOpacity: 0.2,
weight: 2,
}}
/>
)}
return (
<>
{/* Main line connecting all points */}
{drawingPoints.length > 1 && (
<Polyline
positions={drawingPoints}
color={color}
weight={3}
dashArray={dashArray}
opacity={0.8}
/>
)}
{/* Red snap indicator - show when hovering near a device with full ports */}
{nearbyFullDevice && nearbyFullDevice.geometry.type === 'Point' && (
<Circle
center={[nearbyFullDevice.geometry.coordinates[1], nearbyFullDevice.geometry.coordinates[0]]}
radius={3}
pathOptions={{
color: '#EF4444',
fillColor: '#EF4444',
fillOpacity: 0.2,
weight: 2,
}}
/>
)}
{/* Preview line from last point to cursor */}
{cursorPosition && drawingPoints.length > 0 && (
<Polyline
positions={[drawingPoints[drawingPoints.length - 1], cursorPosition]}
color={color}
weight={3}
dashArray="5, 5"
opacity={0.5}
/>
)}
{/* Drawing preview - only show when actively drawing */}
{isDrawing && drawingPoints.length > 0 && (
<>
{/* Main line connecting all points */}
{drawingPoints.length > 1 && (
<Polyline
positions={drawingPoints}
color={color}
weight={3}
dashArray={dashArray}
opacity={0.8}
/>
)}
{/* Markers at each point */}
{drawingPoints.map((point, idx) => (
<Marker key={idx} position={point} />
))}
</>
);
}
{/* Preview line from last point to cursor */}
{cursorPosition && drawingPoints.length > 0 && (
<Polyline
positions={[drawingPoints[drawingPoints.length - 1], cursorPosition]}
color={color}
weight={3}
dashArray="5, 5"
opacity={0.5}
/>
)}
return null;
{/* Markers at each point */}
{drawingPoints.map((point, idx) => (
<Marker key={idx} position={point} />
))}
</>
)}
</>
);
}
@@ -27,7 +27,7 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
const fileInputRef = useRef<HTMLInputElement>(null);
const isSwitch = item.type === 'switch';
const isDevice = ['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type);
const isDevice = ['switch', 'indoor_ap', 'outdoor_ap', 'other_device'].includes(item.type);
const hasConnections = item.properties.connections && item.properties.connections.length > 0;
useEffect(() => {
@@ -1,5 +1,3 @@
import { useState } from 'react';
interface LayerInfo {
name: string;
url: string;
+21 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { Polyline, Marker, Popup, Circle, Tooltip, useMapEvents } from 'react-leaflet';
import { Polyline, Marker, Popup, Circle, useMapEvents } from 'react-leaflet';
import L from 'leaflet';
import { mapItemService } from '../../services/mapItemService';
import { uploadService } from '../../services/uploadService';
@@ -88,6 +88,22 @@ const outdoorApIcon = new L.DivIcon({
iconAnchor: [20, 40],
});
const otherDeviceIcon = new L.DivIcon({
html: `<div class="device-icon other-device-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<rect x="4" y="6" width="16" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<circle cx="12" cy="12" r="2"/>
<line x1="7" y1="9" x2="7" y2="9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="17" y1="9" x2="17" y2="9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="7" y1="15" x2="7" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="17" y1="15" x2="17" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>`,
className: 'custom-device-marker',
iconSize: [40, 40],
iconAnchor: [20, 40],
});
const infoIcon = new L.DivIcon({
html: `<div class="info-marker-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
@@ -127,7 +143,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
console.log('Loaded items:', data);
// Log devices with their connections
data.forEach(item => {
if (['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type)) {
if (['switch', 'indoor_ap', 'outdoor_ap', 'other_device'].includes(item.type)) {
console.log(`Device ${item.type} (${item.id}): ${item.properties.connections?.length || 0} / ${item.properties.port_count} ports`);
}
});
@@ -153,6 +169,8 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
return indoorApIcon;
case 'outdoor_ap':
return outdoorApIcon;
case 'other_device':
return otherDeviceIcon;
case 'info':
return infoIcon;
default:
@@ -379,7 +397,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
// Render devices and info markers
if (
['switch', 'indoor_ap', 'outdoor_ap', 'info'].includes(item.type) &&
['switch', 'indoor_ap', 'outdoor_ap', 'other_device', 'info'].includes(item.type) &&
item.geometry.type === 'Point'
) {
const [lng, lat] = item.geometry.coordinates;
+58 -2
View File
@@ -1,10 +1,21 @@
import { useState, useEffect } from 'react';
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { DrawingHandler } from './DrawingHandler';
import { MapItemsLayer } from './MapItemsLayer';
import { ShareDialog } from './ShareDialog';
import { useMapWebSocket } from '../../hooks/useMapWebSocket';
import { mapItemService } from '../../services/mapItemService';
// Fix Leaflet's default icon paths for production builds
// Since we use custom DivIcons, we just need to prevent 404s
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
iconRetinaUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
shadowUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
});
interface MapViewProps {
mapId: string | null;
@@ -28,6 +39,50 @@ function MapController() {
return null;
}
function AutoZoom({ mapId }: { mapId: string }) {
const map = useMap();
useEffect(() => {
const zoomToDevices = async () => {
try {
const items = await mapItemService.getMapItems(mapId);
// Filter only devices (exclude cables, wireless mesh, and info markers)
const devices = items.filter(item =>
['switch', 'indoor_ap', 'outdoor_ap', 'other_device'].includes(item.type) &&
item.geometry.type === 'Point'
);
if (devices.length === 0) {
// No devices, keep default view
return;
}
// Create bounds from all device coordinates
const bounds = L.latLngBounds(
devices.map(device => {
const coords = (device.geometry as GeoJSON.Point).coordinates;
const [lng, lat] = coords;
return [lat, lng] as [number, number];
})
);
// Fit map to bounds with padding
map.fitBounds(bounds, {
padding: [50, 50],
maxZoom: 18,
});
} catch (error) {
console.error('Failed to auto-zoom to devices:', error);
}
};
zoomToDevices();
}, [mapId, map]);
return null;
}
export function MapView({ mapId, activeLayer, mapLayers, showShareDialog = false, shareMapId, onCloseShareDialog }: MapViewProps) {
const [refreshTrigger, setRefreshTrigger] = useState(0);
@@ -37,7 +92,7 @@ export function MapView({ mapId, activeLayer, mapLayers, showShareDialog = false
};
// WebSocket connection for real-time updates
const { isConnected, permission } = useMapWebSocket({
const { permission } = useMapWebSocket({
mapId: mapId || '',
onItemCreated: (item) => {
console.log('Real-time item created:', item);
@@ -79,6 +134,7 @@ export function MapView({ mapId, activeLayer, mapLayers, showShareDialog = false
style={{ background: '#f0f0f0' }}
>
<MapController />
<AutoZoom mapId={mapId} />
<TileLayer
key={activeLayer}
url={layer.url}
@@ -89,7 +145,7 @@ export function MapView({ mapId, activeLayer, mapLayers, showShareDialog = false
{/* Drawing handler for creating new items - disabled for read-only */}
{permission !== 'read' && (
<DrawingHandler mapId={mapId} onItemCreated={handleItemCreated} />
<DrawingHandler mapId={mapId} onItemCreated={handleItemCreated} refreshTrigger={refreshTrigger} />
)}
{/* Render existing map items */}
+14 -1
View File
@@ -59,7 +59,20 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
showToast('Map shared successfully!', 'success');
} catch (error: any) {
console.error('Share error:', error);
const message = error.response?.data?.detail || error.message || 'Failed to share map';
let message = 'Failed to share map';
if (error.response?.data?.detail) {
const detail = error.response.data.detail;
// Handle FastAPI validation errors (array of objects)
if (Array.isArray(detail)) {
message = detail.map((err: any) => err.msg).join(', ');
} else if (typeof detail === 'string') {
message = detail;
}
} else if (error.message) {
message = error.message;
}
showToast(message, 'error');
} finally {
setLoading(false);
+20 -2
View File
@@ -3,7 +3,6 @@ import type { DrawingTool } from '../../types/mapItem';
import { CABLE_COLORS, CABLE_LABELS } from '../../types/mapItem';
interface ToolbarProps {
mapId: string;
readOnly?: boolean;
}
@@ -72,6 +71,12 @@ const DEVICE_TOOLS: ToolButton[] = [
icon: 'outdoor_ap',
description: 'Outdoor Access Point',
},
{
id: 'other_device',
label: 'Other Device',
icon: 'other_device',
description: 'Other Device',
},
];
const INFO_TOOL: ToolButton = {
@@ -81,7 +86,7 @@ const INFO_TOOL: ToolButton = {
description: 'Information Marker',
};
export function Toolbar({ mapId, readOnly = false }: ToolbarProps) {
export function Toolbar({ readOnly = false }: ToolbarProps) {
const { activeTool, setActiveTool } = useDrawingStore();
const renderIcon = (tool: ToolButton, isDisabled: boolean) => {
@@ -129,6 +134,19 @@ export function Toolbar({ mapId, readOnly = false }: ToolbarProps) {
);
}
if (tool.icon === 'other_device') {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: '#6B7280' }}>
<rect x="4" y="6" width="16" height="12" rx="2" fill="none" stroke="currentColor" strokeWidth="2"/>
<circle cx="12" cy="12" r="2"/>
<line x1="7" y1="9" x2="7" y2="9" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="17" y1="9" x2="17" y2="9" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="7" y1="15" x2="7" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="17" y1="15" x2="17" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
);
}
if (tool.icon === 'info') {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: '#6366F1' }}>
+3 -3
View File
@@ -26,7 +26,7 @@ export function useMapWebSocket({
const [isConnected, setIsConnected] = useState(false);
const [permission, setPermission] = useState<'read' | 'edit' | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
const reconnectAttemptsRef = useRef(0);
// Use refs for callbacks to avoid reconnecting when they change
@@ -51,9 +51,9 @@ export function useMapWebSocket({
// Get the token for authenticated users
const token = authService.getAccessToken();
// Build WebSocket URL
// Build WebSocket URL - always use current domain
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = import.meta.env.VITE_API_URL?.replace(/^https?:\/\//, '') || window.location.host;
const wsHost = window.location.host;
let wsUrl = `${wsProtocol}//${wsHost}/ws/maps/${mapId}`;
// Add authentication
+5
View File
@@ -138,6 +138,11 @@
color: #F59E0B;
}
.other-device-icon {
border-color: #6B7280;
color: #6B7280;
}
/* Info marker icon */
.custom-info-marker {
background: transparent !important;
+9 -9
View File
@@ -8,12 +8,6 @@ import { LayerSwitcher } from '../components/map/LayerSwitcher';
type MapLayer = 'osm' | 'google' | 'esri';
const MAP_LAYERS = {
osm: {
name: 'OpenStreetMap',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 25,
},
google: {
name: 'Google Satellite',
url: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
@@ -21,6 +15,12 @@ const MAP_LAYERS = {
maxZoom: 25,
maxNativeZoom: 22,
},
osm: {
name: 'OpenStreetMap',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 25,
},
esri: {
name: 'ESRI Satellite',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
@@ -33,7 +33,7 @@ export function Dashboard() {
const [selectedMapId, setSelectedMapId] = useState<string | null>(null);
const [showShareDialog, setShowShareDialog] = useState(false);
const [shareMapId, setShareMapId] = useState<string | null>(null);
const [activeLayer, setActiveLayer] = useState<MapLayer>('osm');
const [activeLayer, setActiveLayer] = useState<MapLayer>('google');
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const handleShareMap = (mapId: string) => {
@@ -76,7 +76,7 @@ export function Dashboard() {
{selectedMapId && (
<>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toolbar mapId={selectedMapId} />
<Toolbar />
</div>
{/* Map Style section */}
@@ -84,7 +84,7 @@ export function Dashboard() {
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 px-1 mb-2">Map Style</h3>
<LayerSwitcher
activeLayer={activeLayer}
onLayerChange={setActiveLayer}
onLayerChange={(layer) => setActiveLayer(layer as MapLayer)}
layers={MAP_LAYERS}
/>
</div>
+68 -14
View File
@@ -1,25 +1,29 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { LayerSwitcher } from '../components/map/LayerSwitcher';
import { DrawingHandler } from '../components/map/DrawingHandler';
import { MapItemsLayer } from '../components/map/MapItemsLayer';
import { Toolbar } from '../components/map/Toolbar';
import { MapView } from '../components/map/MapView';
import { useMapWebSocket } from '../hooks/useMapWebSocket';
import { apiClient } from '../services/api';
import { useUIStore } from '../stores/uiStore';
import { mapItemService } from '../services/mapItemService';
// Fix Leaflet's default icon paths for production builds
// Since we use custom DivIcons, we just need to prevent 404s
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
iconRetinaUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
shadowUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
});
type MapLayer = 'osm' | 'google' | 'esri';
const MAP_LAYERS = {
osm: {
name: 'OpenStreetMap',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 25,
},
google: {
name: 'Google Satellite',
url: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
@@ -27,6 +31,12 @@ const MAP_LAYERS = {
maxZoom: 25,
maxNativeZoom: 22,
},
osm: {
name: 'OpenStreetMap',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 25,
},
esri: {
name: 'ESRI Satellite',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
@@ -47,11 +57,54 @@ function MapController() {
return null;
}
function AutoZoom({ mapId }: { mapId: string }) {
const map = useMap();
useEffect(() => {
const zoomToDevices = async () => {
try {
const items = await mapItemService.getMapItems(mapId);
// Filter only devices (exclude cables, wireless mesh, and info markers)
const devices = items.filter(item =>
['switch', 'indoor_ap', 'outdoor_ap', 'other_device'].includes(item.type) &&
item.geometry.type === 'Point'
);
if (devices.length === 0) {
// No devices, keep default view
return;
}
// Create bounds from all device coordinates
const bounds = L.latLngBounds(
devices.map(device => {
const coords = (device.geometry as GeoJSON.Point).coordinates;
const [lng, lat] = coords;
return [lat, lng] as [number, number];
})
);
// Fit map to bounds with padding
map.fitBounds(bounds, {
padding: [50, 50],
maxZoom: 18,
});
} catch (error) {
console.error('Failed to auto-zoom to devices:', error);
}
};
zoomToDevices();
}, [mapId, map]);
return null;
}
export function SharedMap() {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const { darkMode, toggleDarkMode } = useUIStore();
const [activeLayer, setActiveLayer] = useState<MapLayer>('osm');
const [activeLayer, setActiveLayer] = useState<MapLayer>('google');
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [mapData, setMapData] = useState<any>(null);
const [loading, setLoading] = useState(true);
@@ -201,7 +254,7 @@ export function SharedMap() {
style={{ zIndex: 9999 }}
>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toolbar mapId={mapData.id} readOnly={isReadOnly} />
<Toolbar readOnly={isReadOnly} />
</div>
{/* Map Style section */}
@@ -209,7 +262,7 @@ export function SharedMap() {
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 px-1 mb-2">Map Style</h3>
<LayerSwitcher
activeLayer={activeLayer}
onLayerChange={setActiveLayer}
onLayerChange={(layer) => setActiveLayer(layer as MapLayer)}
layers={MAP_LAYERS}
/>
</div>
@@ -224,17 +277,18 @@ export function SharedMap() {
style={{ background: '#f0f0f0' }}
>
<MapController />
<AutoZoom mapId={mapData.id} />
<TileLayer
key={activeLayer}
url={MAP_LAYERS[activeLayer].url}
attribution={MAP_LAYERS[activeLayer].attribution}
maxZoom={MAP_LAYERS[activeLayer].maxZoom}
maxNativeZoom={MAP_LAYERS[activeLayer].maxNativeZoom}
maxNativeZoom={(MAP_LAYERS[activeLayer] as any).maxNativeZoom}
/>
{/* Drawing handler for edit access */}
{!isReadOnly && (
<DrawingHandler mapId={mapData.id} onItemCreated={handleItemCreated} />
<DrawingHandler mapId={mapData.id} onItemCreated={handleItemCreated} refreshTrigger={refreshTrigger} />
)}
{/* Render existing map items */}
+2 -1
View File
@@ -1,6 +1,7 @@
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Always use current domain - nginx proxies /api to backend
const API_URL = window.location.origin;
export const apiClient = axios.create({
baseURL: API_URL,
+1 -1
View File
@@ -23,7 +23,7 @@ export interface MapShareLink {
}
export interface CreateMapShare {
user_id: string;
user_identifier: string;
permission: 'read' | 'edit';
}
+2 -1
View File
@@ -25,7 +25,8 @@ class UploadService {
}
getImageUrl(path: string): string {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Always use current domain
const apiUrl = window.location.origin;
// If it's a path like /api/uploads/image/xxx.jpg
if (path.startsWith('/api/uploads/image/')) {
+2 -1
View File
@@ -1,4 +1,4 @@
export type MapItemType = 'cable' | 'switch' | 'indoor_ap' | 'outdoor_ap' | 'wireless_mesh' | 'info';
export type MapItemType = 'cable' | 'switch' | 'indoor_ap' | 'outdoor_ap' | 'other_device' | 'wireless_mesh' | 'info';
export type CableType = 'fiber' | 'cat6' | 'cat6_poe';
@@ -60,6 +60,7 @@ export type DrawingTool =
| 'switch'
| 'indoor_ap'
| 'outdoor_ap'
| 'other_device'
| 'wireless_mesh'
| 'info';
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""
Script to manage users for the ISP Wiremap application.
Supports password reset, user deletion, and user listing.
Usage:
python scripts/manage_users.py list
python scripts/manage_users.py reset-password <username|email|user_id>
python scripts/manage_users.py delete <username|email|user_id>
python scripts/manage_users.py toggle-admin <username|email|user_id>
"""
import sys
import argparse
from pathlib import Path
from typing import Optional
# Add parent directory to path to import app modules
sys.path.append(str(Path(__file__).resolve().parents[1]))
from app.database import SessionLocal
from app.models.user import User
from app.utils.password import hash_password
import getpass
from sqlalchemy import or_
from uuid import UUID
def get_user(db, identifier: str) -> Optional[User]:
"""
Find a user by username, email, or UUID.
Args:
db: Database session
identifier: Username, email, or user UUID
Returns:
User object if found, None otherwise
"""
# Try to parse as UUID first
try:
user_uuid = UUID(identifier)
user = db.query(User).filter(User.id == user_uuid).first()
if user:
return user
except (ValueError, AttributeError):
pass
# Search by username or email
user = db.query(User).filter(
or_(User.username == identifier, User.email == identifier)
).first()
return user
def list_users():
"""List all users in the system."""
print("=" * 80)
print("ISP Wiremap - User List")
print("=" * 80)
print()
db = SessionLocal()
try:
users = db.query(User).order_by(User.created_at.desc()).all()
if not users:
print("No users found in the system.")
return
print(f"Total users: {len(users)}")
print()
print(f"{'Username':<20} {'Email':<30} {'Admin':<8} {'Created':<20} {'User ID'}")
print("-" * 80)
for user in users:
created_at = user.created_at.strftime("%Y-%m-%d %H:%M:%S") if user.created_at else "N/A"
admin_status = "Yes" if user.is_admin else "No"
print(f"{user.username:<20} {user.email:<30} {admin_status:<8} {created_at:<20} {user.id}")
print()
except Exception as e:
print(f"Error listing users: {e}")
finally:
db.close()
def reset_password(identifier: str):
"""
Reset a user's password.
Args:
identifier: Username, email, or user UUID
"""
print("=" * 80)
print("ISP Wiremap - Reset User Password")
print("=" * 80)
print()
db = SessionLocal()
try:
user = get_user(db, identifier)
if not user:
print(f"Error: User '{identifier}' not found")
print("You can search by username, email, or user ID")
return
# Display user info
print(f"Found user:")
print(f" Username: {user.username}")
print(f" Email: {user.email}")
print(f" User ID: {user.id}")
print(f" Admin: {'Yes' if user.is_admin else 'No'}")
print()
# Confirm action
confirm = input("Do you want to reset this user's password? (yes/no): ").strip().lower()
if confirm not in ['yes', 'y']:
print("Password reset cancelled.")
return
# Get new password
password = getpass.getpass("Enter new password: ")
if not password:
print("Error: Password cannot be empty")
return
password_confirm = getpass.getpass("Confirm new password: ")
if password != password_confirm:
print("Error: Passwords do not match")
return
# Update password
user.password_hash = hash_password(password)
db.commit()
print()
print("=" * 80)
print("Password reset successfully!")
print(f"Username: {user.username}")
print(f"User ID: {user.id}")
print("=" * 80)
except Exception as e:
db.rollback()
print(f"Error resetting password: {e}")
finally:
db.close()
def delete_user(identifier: str):
"""
Delete a user from the system.
Args:
identifier: Username, email, or user UUID
"""
print("=" * 80)
print("ISP Wiremap - Delete User")
print("=" * 80)
print()
db = SessionLocal()
try:
user = get_user(db, identifier)
if not user:
print(f"Error: User '{identifier}' not found")
print("You can search by username, email, or user ID")
return
# Display user info
print(f"Found user:")
print(f" Username: {user.username}")
print(f" Email: {user.email}")
print(f" User ID: {user.id}")
print(f" Admin: {'Yes' if user.is_admin else 'No'}")
print()
# Confirm action
print("WARNING: This action cannot be undone!")
print("All maps, items, and shares owned by this user will also be affected.")
confirm = input("Type the username exactly to confirm deletion: ").strip()
if confirm != user.username:
print("Username does not match. Deletion cancelled.")
return
# Delete user
username = user.username
user_id = user.id
db.delete(user)
db.commit()
print()
print("=" * 80)
print("User deleted successfully!")
print(f"Deleted username: {username}")
print(f"Deleted user ID: {user_id}")
print("=" * 80)
except Exception as e:
db.rollback()
print(f"Error deleting user: {e}")
print("Note: The user may have related records that prevent deletion.")
print("You may need to manually handle foreign key constraints.")
finally:
db.close()
def toggle_admin(identifier: str):
"""
Toggle admin status for a user.
Args:
identifier: Username, email, or user UUID
"""
print("=" * 80)
print("ISP Wiremap - Toggle Admin Status")
print("=" * 80)
print()
db = SessionLocal()
try:
user = get_user(db, identifier)
if not user:
print(f"Error: User '{identifier}' not found")
print("You can search by username, email, or user ID")
return
# Display user info
current_status = "Yes" if user.is_admin else "No"
new_status = "No" if user.is_admin else "Yes"
print(f"Found user:")
print(f" Username: {user.username}")
print(f" Email: {user.email}")
print(f" User ID: {user.id}")
print(f" Current admin status: {current_status}")
print(f" New admin status will be: {new_status}")
print()
# Confirm action
confirm = input(f"Toggle admin status to '{new_status}'? (yes/no): ").strip().lower()
if confirm not in ['yes', 'y']:
print("Operation cancelled.")
return
# Toggle admin status
user.is_admin = not user.is_admin
db.commit()
print()
print("=" * 80)
print("Admin status updated successfully!")
print(f"Username: {user.username}")
print(f"Admin status: {'Yes' if user.is_admin else 'No'}")
print("=" * 80)
except Exception as e:
db.rollback()
print(f"Error toggling admin status: {e}")
finally:
db.close()
def main():
"""Main entry point for the script."""
parser = argparse.ArgumentParser(
description="Manage users in the ISP Wiremap application",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s list
%(prog)s reset-password john_doe
%(prog)s reset-password john@example.com
%(prog)s reset-password 3d2870bc-7dee-476a-a9d0-432a4c9519e9
%(prog)s delete john_doe
%(prog)s toggle-admin john_doe
"""
)
subparsers = parser.add_subparsers(dest='command', help='Command to execute')
# List command
subparsers.add_parser('list', help='List all users')
# Reset password command
reset_parser = subparsers.add_parser('reset-password', help='Reset a user\'s password')
reset_parser.add_argument('identifier', help='Username, email, or user ID')
# Delete command
delete_parser = subparsers.add_parser('delete', help='Delete a user')
delete_parser.add_argument('identifier', help='Username, email, or user ID')
# Toggle admin command
admin_parser = subparsers.add_parser('toggle-admin', help='Toggle admin status for a user')
admin_parser.add_argument('identifier', help='Username, email, or user ID')
args = parser.parse_args()
if not args.command:
parser.print_help()
return
if args.command == 'list':
list_users()
elif args.command == 'reset-password':
reset_password(args.identifier)
elif args.command == 'delete':
delete_user(args.identifier)
elif args.command == 'toggle-admin':
toggle_admin(args.identifier)
else:
parser.print_help()
if __name__ == "__main__":
main()