Drone flights and Bulk Logging WIPs
This commit is contained in:
@@ -17,8 +17,11 @@ depends_on = None
|
||||
def upgrade() -> None:
|
||||
op.add_column('submitted', sa.Column('acknowledged_dt', sa.DateTime(), nullable=True))
|
||||
op.add_column('submitted', sa.Column('acknowledged_by', sa.String(length=50), nullable=True))
|
||||
op.alter_column('local_flights', 'pob', existing_type=sa.Integer(), nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("UPDATE local_flights SET pob = 1 WHERE pob IS NULL")
|
||||
op.alter_column('local_flights', 'pob', existing_type=sa.Integer(), nullable=False)
|
||||
op.drop_column('submitted', 'acknowledged_by')
|
||||
op.drop_column('submitted', 'acknowledged_dt')
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Add drone flight requests
|
||||
|
||||
Revision ID: 009_drone_requests
|
||||
Revises: 008_ppr_strip_acknowledgement
|
||||
Create Date: 2026-06-19 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '009_drone_requests'
|
||||
down_revision = '008_ppr_strip_acknowledgement'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
drone_status = sa.Enum(
|
||||
'NEW',
|
||||
'APPROVED',
|
||||
'DENIED',
|
||||
'PENDING',
|
||||
'CANCELED',
|
||||
'INFLIGHT',
|
||||
'COMPLETED',
|
||||
name='dronerequeststatus',
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'drone_requests',
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('reference_number', sa.String(length=24), nullable=False),
|
||||
sa.Column('public_token', sa.String(length=128), nullable=True),
|
||||
sa.Column('status', drone_status, nullable=False),
|
||||
sa.Column('operator_name', sa.String(length=128), nullable=False),
|
||||
sa.Column('operator_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('flyer_name', sa.String(length=128), nullable=True),
|
||||
sa.Column('flyer_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('email', sa.String(length=128), nullable=False),
|
||||
sa.Column('phone', sa.String(length=32), nullable=True),
|
||||
sa.Column('flight_date', sa.Date(), nullable=True),
|
||||
sa.Column('estimated_takeoff_time', sa.String(length=8), nullable=True),
|
||||
sa.Column('estimated_completion_time', sa.String(length=8), nullable=True),
|
||||
sa.Column('estimated_takeoff_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('estimated_completion_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('maximum_elevation_ft_amsl', sa.Integer(), nullable=False),
|
||||
sa.Column('location_description', sa.Text(), nullable=True),
|
||||
sa.Column('location_latitude', sa.Float(), nullable=False),
|
||||
sa.Column('location_longitude', sa.Float(), nullable=False),
|
||||
sa.Column('location_inside_frz', sa.Boolean(), nullable=True),
|
||||
sa.Column('prototype_overlay', sa.JSON(), nullable=True),
|
||||
sa.Column('applicant_notes', sa.Text(), nullable=True),
|
||||
sa.Column('operator_comments', sa.Text(), nullable=True),
|
||||
sa.Column('submitted_via', sa.String(length=32), nullable=False, server_default='PUBLIC'),
|
||||
sa.Column('submitted_ip', sa.String(length=45), nullable=True),
|
||||
sa.Column('created_by', sa.String(length=50), nullable=True),
|
||||
sa.Column('submitted_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('status_changed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('status_changed_by', sa.String(length=50), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('reference_number'),
|
||||
)
|
||||
op.create_index(op.f('ix_drone_requests_created_by'), 'drone_requests', ['created_by'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_email'), 'drone_requests', ['email'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_estimated_completion_at'), 'drone_requests', ['estimated_completion_at'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_estimated_takeoff_at'), 'drone_requests', ['estimated_takeoff_at'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_flight_date'), 'drone_requests', ['flight_date'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_operator_name'), 'drone_requests', ['operator_name'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_public_token'), 'drone_requests', ['public_token'], unique=True)
|
||||
op.create_index(op.f('ix_drone_requests_reference_number'), 'drone_requests', ['reference_number'], unique=True)
|
||||
op.create_index(op.f('ix_drone_requests_status'), 'drone_requests', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_submitted_at'), 'drone_requests', ['submitted_at'], unique=False)
|
||||
op.create_index('idx_drone_status_takeoff', 'drone_requests', ['status', 'estimated_takeoff_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_drone_status_takeoff', table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_submitted_at'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_status'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_reference_number'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_public_token'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_operator_name'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_flight_date'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_estimated_takeoff_at'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_estimated_completion_at'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_email'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_created_by'), table_name='drone_requests')
|
||||
op.drop_table('drone_requests')
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights, public_book, movements
|
||||
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights, public_book, movements, drone_requests
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -12,7 +12,8 @@ api_router.include_router(overflights.router, prefix="/overflights", tags=["over
|
||||
api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"])
|
||||
api_router.include_router(journal.router, prefix="/journal", tags=["journal"])
|
||||
api_router.include_router(movements.router, prefix="/movements", tags=["movements"])
|
||||
api_router.include_router(drone_requests.router, prefix="/drone-requests", tags=["drone_requests"])
|
||||
api_router.include_router(public.router, prefix="/public", tags=["public"])
|
||||
api_router.include_router(public_book.router, prefix="/public-book", tags=["public_booking"])
|
||||
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
|
||||
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
|
||||
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_operator_user, get_current_read_user, get_db
|
||||
from app.core.email import email_service
|
||||
from app.core.config import settings
|
||||
from app.core.utils import get_client_ip
|
||||
from app.crud.crud_drone_request import drone_request as crud_drone_request
|
||||
from app.crud.crud_journal import journal as crud_journal
|
||||
from app.models.journal import EntityType
|
||||
from app.models.ppr import User
|
||||
from app.schemas.drone_request import (
|
||||
DroneRequest,
|
||||
DroneRequestComment,
|
||||
DroneRequestCreate,
|
||||
DroneRequestStatus,
|
||||
DroneRequestStatusUpdate,
|
||||
DroneRequestUpdate,
|
||||
)
|
||||
from app.schemas.journal import JournalEntryResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _broadcast(request: Request, event_type: str, drone_request: DroneRequest):
|
||||
if hasattr(request.app.state, "connection_manager"):
|
||||
await request.app.state.connection_manager.broadcast({
|
||||
"type": event_type,
|
||||
"data": {
|
||||
"id": drone_request.id,
|
||||
"reference_number": drone_request.reference_number,
|
||||
"status": drone_request.status.value,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
async def _send_drone_email(drone_request, subject: str, message: str):
|
||||
await email_service.send_email(
|
||||
to_email=drone_request.email,
|
||||
subject=subject,
|
||||
template_name="drone_request_update.html",
|
||||
template_vars={
|
||||
"name": drone_request.operator_name,
|
||||
"reference_number": drone_request.reference_number,
|
||||
"status": drone_request.status.value,
|
||||
"message": message,
|
||||
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
||||
"maximum_elevation_ft_amsl": drone_request.maximum_elevation_ft_amsl,
|
||||
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[DroneRequest])
|
||||
async def get_drone_requests(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[DroneRequestStatus] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_read_user),
|
||||
):
|
||||
return crud_drone_request.get_multi(
|
||||
db,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
status=status,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/public", response_model=DroneRequest)
|
||||
async def create_public_drone_request(
|
||||
request: Request,
|
||||
drone_request_in: DroneRequestCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
client_ip = get_client_ip(request)
|
||||
drone_request = crud_drone_request.create(
|
||||
db,
|
||||
obj_in=drone_request_in,
|
||||
created_by="public",
|
||||
user_ip=client_ip,
|
||||
submitted_via="PUBLIC",
|
||||
)
|
||||
|
||||
await _broadcast(request, "drone_request_created", drone_request)
|
||||
await _send_drone_email(
|
||||
drone_request,
|
||||
f"Drone flight request received {drone_request.reference_number}",
|
||||
"We have received your drone flight request. We will email you when the approval status changes or if we need more information.",
|
||||
)
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.get("/public/edit/{token}", response_model=DroneRequest)
|
||||
async def get_drone_request_for_edit(
|
||||
token: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
drone_request = crud_drone_request.get_by_public_token(db, token)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.patch("/public/edit/{token}", response_model=DroneRequest)
|
||||
async def update_drone_request_public(
|
||||
token: str,
|
||||
drone_request_in: DroneRequestUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
drone_request = crud_drone_request.get_by_public_token(db, token)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
|
||||
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.PENDING, DroneRequestStatus.APPROVED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Drone request cannot be edited while {drone_request.status.value}",
|
||||
)
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
updated_request = crud_drone_request.update(
|
||||
db,
|
||||
db_obj=drone_request,
|
||||
obj_in=drone_request_in,
|
||||
user="public",
|
||||
user_ip=client_ip,
|
||||
)
|
||||
await _broadcast(request, "drone_request_updated", updated_request)
|
||||
return updated_request
|
||||
|
||||
|
||||
@router.delete("/public/cancel/{token}", response_model=DroneRequest)
|
||||
async def cancel_drone_request_public(
|
||||
token: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
drone_request = crud_drone_request.get_by_public_token(db, token)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
|
||||
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.PENDING, DroneRequestStatus.APPROVED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Drone request cannot be cancelled while {drone_request.status.value}",
|
||||
)
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
cancelled_request = crud_drone_request.update_status(
|
||||
db,
|
||||
request_id=drone_request.id,
|
||||
status=DroneRequestStatus.CANCELED,
|
||||
comment="Cancelled by operator using secure link",
|
||||
user="public",
|
||||
user_ip=client_ip,
|
||||
)
|
||||
await _broadcast(request, "drone_request_status_update", cancelled_request)
|
||||
await _send_drone_email(
|
||||
cancelled_request,
|
||||
f"Drone request {cancelled_request.reference_number} CANCELED",
|
||||
"Your drone flight request has been cancelled.",
|
||||
)
|
||||
return cancelled_request
|
||||
|
||||
|
||||
@router.get("/{request_id}", response_model=DroneRequest)
|
||||
async def get_drone_request(
|
||||
request_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_read_user),
|
||||
):
|
||||
drone_request = crud_drone_request.get(db, request_id)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.patch("/{request_id}", response_model=DroneRequest)
|
||||
async def update_drone_request(
|
||||
request: Request,
|
||||
request_id: int,
|
||||
drone_request_in: DroneRequestUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_operator_user),
|
||||
):
|
||||
db_request = crud_drone_request.get(db, request_id)
|
||||
if not db_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
drone_request = crud_drone_request.update(
|
||||
db,
|
||||
db_obj=db_request,
|
||||
obj_in=drone_request_in,
|
||||
user=current_user.username,
|
||||
user_ip=client_ip,
|
||||
)
|
||||
await _broadcast(request, "drone_request_updated", drone_request)
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.patch("/{request_id}/status", response_model=DroneRequest)
|
||||
async def update_drone_request_status(
|
||||
request: Request,
|
||||
request_id: int,
|
||||
status_update: DroneRequestStatusUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_operator_user),
|
||||
):
|
||||
client_ip = get_client_ip(request)
|
||||
drone_request = crud_drone_request.update_status(
|
||||
db,
|
||||
request_id=request_id,
|
||||
status=status_update.status,
|
||||
comment=status_update.comment,
|
||||
user=current_user.username,
|
||||
user_ip=client_ip,
|
||||
)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
|
||||
|
||||
await _broadcast(request, "drone_request_status_update", drone_request)
|
||||
message = status_update.comment or f"Your drone flight request status is now {drone_request.status.value}."
|
||||
await _send_drone_email(
|
||||
drone_request,
|
||||
f"Drone request {drone_request.reference_number} {drone_request.status.value}",
|
||||
message,
|
||||
)
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.post("/{request_id}/comments", response_model=DroneRequest)
|
||||
async def add_drone_request_comment(
|
||||
request: Request,
|
||||
request_id: int,
|
||||
comment_in: DroneRequestComment,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_operator_user),
|
||||
):
|
||||
client_ip = get_client_ip(request)
|
||||
drone_request = crud_drone_request.add_comment(
|
||||
db,
|
||||
request_id=request_id,
|
||||
comment=comment_in.comment,
|
||||
user=current_user.username,
|
||||
user_ip=client_ip,
|
||||
)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
|
||||
|
||||
await _broadcast(request, "drone_request_comment_added", drone_request)
|
||||
if comment_in.email_applicant:
|
||||
await _send_drone_email(
|
||||
drone_request,
|
||||
f"Drone request {drone_request.reference_number} update",
|
||||
comment_in.comment,
|
||||
)
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.get("/{request_id}/journal", response_model=List[JournalEntryResponse])
|
||||
async def get_drone_request_journal(
|
||||
request_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_read_user),
|
||||
):
|
||||
drone_request = crud_drone_request.get(db, request_id)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
|
||||
return crud_journal.get_entity_journal(db, EntityType.DRONE_REQUEST, request_id)
|
||||
@@ -143,6 +143,24 @@ def _movement_to_dict(movement: MovementModel) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _local_flight_to_dict(local: LocalFlight) -> dict:
|
||||
return {
|
||||
"id": local.id,
|
||||
"aircraft_registration": local.registration,
|
||||
"aircraft_type": local.type,
|
||||
"callsign": local.callsign,
|
||||
"pob": local.pob,
|
||||
"flight_type": local.flight_type.value if local.flight_type else None,
|
||||
"status": local.status.value if local.status else None,
|
||||
"etd": local.etd.isoformat() if local.etd else None,
|
||||
"takeoff_time": local.takeoff_dt.isoformat() if local.takeoff_dt else None,
|
||||
"departed_time": local.departed_dt.isoformat() if local.departed_dt else None,
|
||||
"landing_time": local.landed_dt.isoformat() if local.landed_dt else None,
|
||||
"circuits": local.circuits,
|
||||
"notes": local.notes,
|
||||
}
|
||||
|
||||
|
||||
def _build_suggestion(pprs: List[PPRRecord], movements: List[MovementModel], flight_kind: str) -> dict:
|
||||
if movements:
|
||||
movement = movements[0]
|
||||
@@ -234,7 +252,8 @@ async def get_bulk_movement_context(
|
||||
entity_type_filter = _strip_entity_type(flight_kind)
|
||||
|
||||
pprs = []
|
||||
if clean_lookup:
|
||||
local_flights = []
|
||||
if clean_lookup and flight_kind.upper() != "LOCAL":
|
||||
pprs = db.query(PPRRecord).filter(
|
||||
_sql_clean_alnum(PPRRecord.ac_reg).like(f"{clean_lookup}%"),
|
||||
or_(
|
||||
@@ -244,6 +263,20 @@ async def get_bulk_movement_context(
|
||||
PPRRecord.status != PPRStatus.DELETED
|
||||
).order_by(PPRRecord.eta).limit(10).all()
|
||||
|
||||
if clean_lookup and flight_kind.upper() == "LOCAL":
|
||||
local_flights = db.query(LocalFlight).filter(
|
||||
_sql_clean_alnum(LocalFlight.registration).like(f"{clean_lookup}%"),
|
||||
or_(
|
||||
func.date(LocalFlight.takeoff_dt) == target_date,
|
||||
func.date(LocalFlight.departed_dt) == target_date,
|
||||
func.date(LocalFlight.landed_dt) == target_date,
|
||||
func.date(LocalFlight.etd) == target_date,
|
||||
func.date(LocalFlight.created_dt) == target_date
|
||||
),
|
||||
LocalFlight.flight_type.in_([LocalFlightType.LOCAL, LocalFlightType.CIRCUITS]),
|
||||
LocalFlight.status != LocalFlightStatus.CANCELLED
|
||||
).order_by(LocalFlight.takeoff_dt, LocalFlight.etd, LocalFlight.created_dt).limit(10).all()
|
||||
|
||||
movements = []
|
||||
if clean_lookup:
|
||||
movements = db.query(MovementModel).filter(
|
||||
@@ -257,6 +290,7 @@ async def get_bulk_movement_context(
|
||||
|
||||
return BulkMovementContext(
|
||||
pprs=[_ppr_to_dict(ppr) for ppr in pprs],
|
||||
local_flights=[_local_flight_to_dict(local) for local in local_flights],
|
||||
movements=[_movement_to_dict(movement) for movement in movements],
|
||||
suggested=_build_suggestion(pprs, movements, flight_kind)
|
||||
)
|
||||
@@ -286,8 +320,8 @@ async def bulk_log_movement(
|
||||
else entry.movement_time
|
||||
) or entry.movement_time
|
||||
timestamp = _combine_date_time(entry.movement_date, primary_time)
|
||||
existing_movement = crud_movement.get(db, entry.movement_id) if entry.movement_id else None
|
||||
if not existing_movement:
|
||||
existing_movement = crud_movement.get(db, entry.movement_id) if entry.movement_id and flight_kind != "LOCAL" else None
|
||||
if not existing_movement and flight_kind != "LOCAL":
|
||||
existing_movement = crud_movement.find_daily_match(
|
||||
db,
|
||||
entry.movement_date,
|
||||
@@ -302,56 +336,34 @@ async def bulk_log_movement(
|
||||
if flight_kind == "LOCAL":
|
||||
takeoff_dt = _combine_date_time(entry.movement_date, entry.takeoff_time)
|
||||
landing_dt = _combine_date_time(entry.movement_date, entry.landing_time)
|
||||
local_type = LocalFlightType.CIRCUITS if (entry.local_nature or "").upper() == "CIRCUITS" else LocalFlightType.LOCAL
|
||||
local = None
|
||||
if existing_movement and existing_movement.entity_type == "LOCAL_FLIGHT":
|
||||
local = db.query(LocalFlight).filter(LocalFlight.id == existing_movement.entity_id).first()
|
||||
if not local:
|
||||
local = db.query(LocalFlight).filter(
|
||||
_sql_clean_alnum(LocalFlight.registration) == clean_lookup,
|
||||
func.date(LocalFlight.takeoff_dt) == entry.movement_date
|
||||
).first()
|
||||
if not local:
|
||||
local = LocalFlight(
|
||||
registration=clean_reg,
|
||||
type=entry.aircraft_type or "",
|
||||
callsign=entry.callsign,
|
||||
pob=entry.pob or 1,
|
||||
flight_type=local_type,
|
||||
status=LocalFlightStatus.LANDED,
|
||||
duration=int((landing_dt - takeoff_dt).total_seconds() / 60) if landing_dt > takeoff_dt else None,
|
||||
circuits=entry.circuits or 0,
|
||||
notes=entry.notes,
|
||||
etd=takeoff_dt,
|
||||
departed_dt=takeoff_dt,
|
||||
takeoff_dt=takeoff_dt,
|
||||
landed_dt=landing_dt,
|
||||
created_by=username,
|
||||
submitted_via=LocalSubmissionSource.ADMIN
|
||||
if landing_dt < takeoff_dt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="LOCAL landing time cannot be before takeoff time"
|
||||
)
|
||||
db.add(local)
|
||||
db.commit()
|
||||
db.refresh(local)
|
||||
action = "created"
|
||||
crud_journal.log_change(db, EntityType.LOCAL_FLIGHT, local.id, "Local strip created from bulk flight log", username, client_ip)
|
||||
else:
|
||||
local.registration = clean_reg
|
||||
local.type = entry.aircraft_type or local.type
|
||||
local.callsign = entry.callsign
|
||||
local.pob = entry.pob or local.pob
|
||||
local.flight_type = local_type
|
||||
local.status = LocalFlightStatus.LANDED
|
||||
local.duration = int((landing_dt - takeoff_dt).total_seconds() / 60) if landing_dt > takeoff_dt else local.duration
|
||||
local.circuits = entry.circuits or 0
|
||||
local.notes = entry.notes
|
||||
local.etd = takeoff_dt
|
||||
local.departed_dt = takeoff_dt
|
||||
local.takeoff_dt = takeoff_dt
|
||||
local.landed_dt = landing_dt
|
||||
db.add(local)
|
||||
db.commit()
|
||||
db.refresh(local)
|
||||
action = "updated"
|
||||
local_type = LocalFlightType.CIRCUITS if (entry.local_nature or "").upper() == "CIRCUITS" else LocalFlightType.LOCAL
|
||||
local = LocalFlight(
|
||||
registration=clean_reg,
|
||||
type=entry.aircraft_type or "",
|
||||
callsign=entry.callsign,
|
||||
pob=entry.pob,
|
||||
flight_type=local_type,
|
||||
status=LocalFlightStatus.LANDED,
|
||||
duration=int((landing_dt - takeoff_dt).total_seconds() / 60) if landing_dt > takeoff_dt else None,
|
||||
circuits=entry.circuits or 0,
|
||||
notes=entry.notes,
|
||||
etd=takeoff_dt,
|
||||
departed_dt=takeoff_dt,
|
||||
takeoff_dt=takeoff_dt,
|
||||
landed_dt=landing_dt,
|
||||
created_by=username,
|
||||
submitted_via=LocalSubmissionSource.ADMIN
|
||||
)
|
||||
db.add(local)
|
||||
db.commit()
|
||||
db.refresh(local)
|
||||
action = "created"
|
||||
crud_journal.log_change(db, EntityType.LOCAL_FLIGHT, local.id, "Local strip created from bulk flight log", username, client_ip)
|
||||
|
||||
takeoff_movement = _create_or_update_movement(db, MovementCreate(
|
||||
movement_type=MovementType.TAKEOFF,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
from datetime import date, datetime
|
||||
import secrets
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import desc, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud.crud_journal import journal as crud_journal
|
||||
from app.models.drone_request import DroneRequest, DroneRequestStatus
|
||||
from app.models.journal import EntityType
|
||||
from app.schemas.drone_request import DroneRequestCreate, DroneRequestUpdate
|
||||
|
||||
|
||||
class CRUDDroneRequest:
|
||||
def get(self, db: Session, request_id: int) -> Optional[DroneRequest]:
|
||||
return db.query(DroneRequest).filter(DroneRequest.id == request_id).first()
|
||||
|
||||
def get_by_reference(self, db: Session, reference_number: str) -> Optional[DroneRequest]:
|
||||
return db.query(DroneRequest).filter(DroneRequest.reference_number == reference_number).first()
|
||||
|
||||
def get_by_public_token(self, db: Session, token: str) -> Optional[DroneRequest]:
|
||||
return db.query(DroneRequest).filter(DroneRequest.public_token == token).first()
|
||||
|
||||
def get_multi(
|
||||
self,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[DroneRequestStatus] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
) -> List[DroneRequest]:
|
||||
query = db.query(DroneRequest)
|
||||
|
||||
if status:
|
||||
query = query.filter(DroneRequest.status == status)
|
||||
if date_from:
|
||||
query = query.filter(func.date(DroneRequest.estimated_takeoff_at) >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(func.date(DroneRequest.estimated_takeoff_at) <= date_to)
|
||||
|
||||
return query.order_by(desc(DroneRequest.submitted_at)).offset(skip).limit(limit).all()
|
||||
|
||||
def create(
|
||||
self,
|
||||
db: Session,
|
||||
obj_in: DroneRequestCreate,
|
||||
created_by: str = "public",
|
||||
user_ip: str = "127.0.0.1",
|
||||
submitted_via: str = "PUBLIC",
|
||||
) -> DroneRequest:
|
||||
reference_number = self._generate_reference(db)
|
||||
payload = obj_in.dict()
|
||||
notes = payload.pop("notes", None)
|
||||
|
||||
db_obj = DroneRequest(
|
||||
**payload,
|
||||
applicant_notes=notes,
|
||||
reference_number=reference_number,
|
||||
public_token=secrets.token_urlsafe(64),
|
||||
status=DroneRequestStatus.NEW,
|
||||
created_by=created_by,
|
||||
submitted_ip=user_ip,
|
||||
submitted_via=submitted_via,
|
||||
)
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
crud_journal.log_change(
|
||||
db,
|
||||
EntityType.DRONE_REQUEST,
|
||||
db_obj.id,
|
||||
f"Drone request {db_obj.reference_number} created",
|
||||
created_by,
|
||||
user_ip,
|
||||
)
|
||||
return db_obj
|
||||
|
||||
def update(
|
||||
self,
|
||||
db: Session,
|
||||
db_obj: DroneRequest,
|
||||
obj_in: DroneRequestUpdate,
|
||||
user: str = "system",
|
||||
user_ip: str = "127.0.0.1",
|
||||
) -> DroneRequest:
|
||||
update_data = obj_in.dict(exclude_unset=True)
|
||||
if "notes" in update_data:
|
||||
update_data["applicant_notes"] = update_data.pop("notes")
|
||||
changes = []
|
||||
|
||||
for field, value in update_data.items():
|
||||
old_value = getattr(db_obj, field)
|
||||
if old_value != value:
|
||||
changes.append(f"{field} changed from '{old_value}' to '{value}'")
|
||||
setattr(db_obj, field, value)
|
||||
|
||||
if changes:
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
for change in changes:
|
||||
crud_journal.log_change(db, EntityType.DRONE_REQUEST, db_obj.id, change, user, user_ip)
|
||||
|
||||
return db_obj
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
db: Session,
|
||||
request_id: int,
|
||||
status: DroneRequestStatus,
|
||||
comment: Optional[str] = None,
|
||||
user: str = "system",
|
||||
user_ip: str = "127.0.0.1",
|
||||
) -> Optional[DroneRequest]:
|
||||
db_obj = self.get(db, request_id)
|
||||
if not db_obj:
|
||||
return None
|
||||
|
||||
old_status = db_obj.status
|
||||
db_obj.status = status
|
||||
db_obj.status_changed_at = datetime.utcnow()
|
||||
db_obj.status_changed_by = user
|
||||
if comment:
|
||||
db_obj.operator_comments = comment
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
entry = f"Status changed from {old_status.value} to {status.value}"
|
||||
if comment:
|
||||
entry = f"{entry}: {comment}"
|
||||
crud_journal.log_change(db, EntityType.DRONE_REQUEST, db_obj.id, entry, user, user_ip)
|
||||
return db_obj
|
||||
|
||||
def add_comment(
|
||||
self,
|
||||
db: Session,
|
||||
request_id: int,
|
||||
comment: str,
|
||||
user: str = "system",
|
||||
user_ip: str = "127.0.0.1",
|
||||
) -> Optional[DroneRequest]:
|
||||
db_obj = self.get(db, request_id)
|
||||
if not db_obj:
|
||||
return None
|
||||
|
||||
db_obj.operator_comments = comment
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
crud_journal.log_change(db, EntityType.DRONE_REQUEST, db_obj.id, f"Comment added: {comment}", user, user_ip)
|
||||
return db_obj
|
||||
|
||||
def _generate_reference(self, db: Session) -> str:
|
||||
prefix = f"DRN-{datetime.utcnow().strftime('%y%m%d')}"
|
||||
references = db.query(DroneRequest.reference_number).filter(
|
||||
DroneRequest.reference_number.like(f"{prefix}-%")
|
||||
).all()
|
||||
|
||||
highest_sequence = 0
|
||||
for (reference_number,) in references:
|
||||
suffix = reference_number.rsplit("-", 1)[-1]
|
||||
if suffix.isdigit():
|
||||
highest_sequence = max(highest_sequence, int(suffix))
|
||||
|
||||
for sequence in range(highest_sequence + 1, highest_sequence + 11):
|
||||
candidate = f"{prefix}-{sequence}"
|
||||
if not self.get_by_reference(db, candidate):
|
||||
return candidate
|
||||
|
||||
return f"{prefix}-{highest_sequence + 11}"
|
||||
|
||||
|
||||
drone_request = CRUDDroneRequest()
|
||||
+2
-1
@@ -16,6 +16,7 @@ from app.models.departure import Departure
|
||||
from app.models.arrival import Arrival
|
||||
from app.models.circuit import Circuit
|
||||
from app.models.movement import Movement
|
||||
from app.models.drone_request import DroneRequest
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -206,4 +207,4 @@ async def health_check():
|
||||
app.include_router(api_router, prefix=settings.api_v1_str)
|
||||
|
||||
# Make connection manager available to the app
|
||||
app.state.connection_manager = manager
|
||||
app.state.connection_manager = manager
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Column, Date, DateTime, Enum as SQLEnum, Float, Index, Integer, JSON, String, Text
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class DroneRequestStatus(str, Enum):
|
||||
NEW = "NEW"
|
||||
APPROVED = "APPROVED"
|
||||
DENIED = "DENIED"
|
||||
PENDING = "PENDING"
|
||||
CANCELED = "CANCELED"
|
||||
INFLIGHT = "INFLIGHT"
|
||||
COMPLETED = "COMPLETED"
|
||||
|
||||
|
||||
class DroneRequest(Base):
|
||||
__tablename__ = "drone_requests"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
reference_number = Column(String(24), nullable=False, unique=True, index=True)
|
||||
public_token = Column(String(128), nullable=True, unique=True, index=True)
|
||||
status = Column(SQLEnum(DroneRequestStatus), nullable=False, default=DroneRequestStatus.NEW, index=True)
|
||||
|
||||
operator_name = Column(String(128), nullable=False, index=True)
|
||||
operator_id = Column(String(64), nullable=True)
|
||||
flyer_name = Column(String(128), nullable=True)
|
||||
flyer_id = Column(String(64), nullable=True)
|
||||
email = Column(String(128), nullable=False, index=True)
|
||||
phone = Column(String(32), nullable=True)
|
||||
|
||||
flight_date = Column(Date, nullable=True, index=True)
|
||||
estimated_takeoff_time = Column(String(8), nullable=True)
|
||||
estimated_completion_time = Column(String(8), nullable=True)
|
||||
estimated_takeoff_at = Column(DateTime, nullable=False, index=True)
|
||||
estimated_completion_at = Column(DateTime, nullable=False, index=True)
|
||||
maximum_elevation_ft_amsl = Column(Integer, nullable=False)
|
||||
|
||||
location_description = Column(Text, nullable=True)
|
||||
location_latitude = Column(Float, nullable=False)
|
||||
location_longitude = Column(Float, nullable=False)
|
||||
location_inside_frz = Column(Boolean, nullable=True)
|
||||
prototype_overlay = Column(JSON, nullable=True)
|
||||
|
||||
applicant_notes = Column(Text, nullable=True)
|
||||
operator_comments = Column(Text, nullable=True)
|
||||
submitted_via = Column(String(32), nullable=False, default="PUBLIC")
|
||||
submitted_ip = Column(String(45), nullable=True)
|
||||
created_by = Column(String(50), nullable=True, index=True)
|
||||
|
||||
submitted_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
|
||||
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||
status_changed_at = Column(DateTime, nullable=True)
|
||||
status_changed_by = Column(String(50), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_drone_status_takeoff", "status", "estimated_takeoff_at"),
|
||||
)
|
||||
|
||||
@property
|
||||
def notes(self):
|
||||
return self.applicant_notes
|
||||
@@ -7,6 +7,7 @@ from app.db.session import Base
|
||||
class EntityType(str, PyEnum):
|
||||
"""Entity types that can have journal entries"""
|
||||
PPR = "PPR"
|
||||
DRONE_REQUEST = "DRONE_REQUEST"
|
||||
LOCAL_FLIGHT = "LOCAL_FLIGHT"
|
||||
ARRIVAL = "ARRIVAL"
|
||||
DEPARTURE = "DEPARTURE"
|
||||
|
||||
@@ -35,7 +35,7 @@ class LocalFlight(Base):
|
||||
registration = Column(String(16), nullable=False, index=True)
|
||||
type = Column(String(32), nullable=False) # Aircraft type
|
||||
callsign = Column(String(16), nullable=True)
|
||||
pob = Column(Integer, nullable=False) # Persons on board
|
||||
pob = Column(Integer, nullable=True) # Persons on board may be unknown for post-event logging
|
||||
flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True)
|
||||
status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True)
|
||||
duration = Column(Integer, nullable=True) # Duration in minutes
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, validator
|
||||
|
||||
|
||||
class DroneRequestStatus(str, Enum):
|
||||
NEW = "NEW"
|
||||
APPROVED = "APPROVED"
|
||||
DENIED = "DENIED"
|
||||
PENDING = "PENDING"
|
||||
CANCELED = "CANCELED"
|
||||
INFLIGHT = "INFLIGHT"
|
||||
COMPLETED = "COMPLETED"
|
||||
|
||||
|
||||
class DroneRequestBase(BaseModel):
|
||||
operator_name: str = Field(..., max_length=128)
|
||||
operator_id: Optional[str] = Field(None, max_length=64)
|
||||
flight_date: Optional[date] = None
|
||||
estimated_takeoff_time: Optional[str] = Field(None, max_length=8)
|
||||
estimated_completion_time: Optional[str] = Field(None, max_length=8)
|
||||
maximum_elevation_ft_amsl: int = Field(..., ge=0)
|
||||
location_description: Optional[str] = None
|
||||
location_latitude: float = Field(..., ge=-90, le=90)
|
||||
location_longitude: float = Field(..., ge=-180, le=180)
|
||||
location_inside_frz: Optional[bool] = None
|
||||
flyer_name: Optional[str] = Field(None, max_length=128)
|
||||
flyer_id: Optional[str] = Field(None, max_length=64)
|
||||
email: EmailStr
|
||||
phone: Optional[str] = Field(None, max_length=32)
|
||||
notes: Optional[str] = None
|
||||
estimated_takeoff_at: datetime
|
||||
estimated_completion_at: datetime
|
||||
prototype_overlay: Optional[dict[str, Any]] = None
|
||||
|
||||
@validator("operator_name")
|
||||
def validate_operator_name(cls, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("Operator name is required")
|
||||
return value
|
||||
|
||||
@validator("location_inside_frz", pre=True)
|
||||
def parse_inside_frz(cls, value):
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"yes", "true", "1", "y"}:
|
||||
return True
|
||||
if normalized in {"no", "false", "0", "n"}:
|
||||
return False
|
||||
return value
|
||||
|
||||
|
||||
class DroneRequestCreate(DroneRequestBase):
|
||||
pass
|
||||
|
||||
|
||||
class DroneRequestUpdate(BaseModel):
|
||||
operator_name: Optional[str] = Field(None, max_length=128)
|
||||
operator_id: Optional[str] = Field(None, max_length=64)
|
||||
flyer_name: Optional[str] = Field(None, max_length=128)
|
||||
flyer_id: Optional[str] = Field(None, max_length=64)
|
||||
email: Optional[EmailStr] = None
|
||||
phone: Optional[str] = Field(None, max_length=32)
|
||||
flight_date: Optional[date] = None
|
||||
estimated_takeoff_time: Optional[str] = Field(None, max_length=8)
|
||||
estimated_completion_time: Optional[str] = Field(None, max_length=8)
|
||||
estimated_takeoff_at: Optional[datetime] = None
|
||||
estimated_completion_at: Optional[datetime] = None
|
||||
maximum_elevation_ft_amsl: Optional[int] = Field(None, ge=0)
|
||||
location_description: Optional[str] = None
|
||||
location_latitude: Optional[float] = Field(None, ge=-90, le=90)
|
||||
location_longitude: Optional[float] = Field(None, ge=-180, le=180)
|
||||
location_inside_frz: Optional[bool] = None
|
||||
notes: Optional[str] = None
|
||||
prototype_overlay: Optional[dict[str, Any]] = None
|
||||
operator_comments: Optional[str] = None
|
||||
|
||||
|
||||
class DroneRequestStatusUpdate(BaseModel):
|
||||
status: DroneRequestStatus
|
||||
comment: Optional[str] = None
|
||||
|
||||
|
||||
class DroneRequestComment(BaseModel):
|
||||
comment: str = Field(..., min_length=1)
|
||||
email_applicant: bool = True
|
||||
|
||||
|
||||
class DroneRequest(DroneRequestBase):
|
||||
id: int
|
||||
reference_number: str
|
||||
status: DroneRequestStatus
|
||||
operator_comments: Optional[str] = None
|
||||
submitted_via: str
|
||||
submitted_ip: Optional[str] = None
|
||||
created_by: Optional[str] = None
|
||||
submitted_at: datetime
|
||||
updated_at: datetime
|
||||
status_changed_at: Optional[datetime] = None
|
||||
status_changed_by: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -32,7 +32,7 @@ class LocalFlightBase(BaseModel):
|
||||
registration: str
|
||||
type: Optional[str] = None # Aircraft type - optional, can be looked up later
|
||||
callsign: Optional[str] = None
|
||||
pob: int
|
||||
pob: Optional[int] = None
|
||||
flight_type: LocalFlightType
|
||||
duration: Optional[int] = 45 # Duration in minutes, default 45
|
||||
etd: Optional[datetime] = None # Estimated Time of Departure
|
||||
@@ -105,4 +105,4 @@ class LocalFlightInDBBase(LocalFlightBase):
|
||||
|
||||
|
||||
class LocalFlight(LocalFlightInDBBase):
|
||||
pass
|
||||
pass
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import date, datetime
|
||||
from app.models.movement import MovementType
|
||||
|
||||
@@ -52,6 +52,7 @@ class BulkMovementLog(BaseModel):
|
||||
|
||||
class BulkMovementContext(BaseModel):
|
||||
pprs: List[dict]
|
||||
local_flights: List[dict] = Field(default_factory=list)
|
||||
movements: List[dict]
|
||||
suggested: dict
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Drone Flight Request Update</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #222;">
|
||||
<h2>Drone Flight Request Update</h2>
|
||||
<p>Hello {{ name }},</p>
|
||||
<p>{{ message }}</p>
|
||||
|
||||
<table cellpadding="6" cellspacing="0" style="border-collapse: collapse;">
|
||||
<tr><td><strong>Reference</strong></td><td>{{ reference_number }}</td></tr>
|
||||
<tr><td><strong>Status</strong></td><td>{{ status }}</td></tr>
|
||||
<tr><td><strong>Takeoff</strong></td><td>{{ takeoff_time }}</td></tr>
|
||||
<tr><td><strong>Completion</strong></td><td>{{ completion_time }}</td></tr>
|
||||
<tr><td><strong>Location</strong></td><td>{{ location }}</td></tr>
|
||||
<tr><td><strong>Max elevation</strong></td><td>{{ maximum_elevation_ft_amsl }} ft AMSL</td></tr>
|
||||
</table>
|
||||
|
||||
{% if edit_url %}
|
||||
<p>You can <a href="{{ edit_url }}">view, update, or cancel your drone request</a> using this secure link.</p>
|
||||
{% endif %}
|
||||
|
||||
<p>Please quote your reference number in any replies.</p>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user