Journaling improvements
This commit is contained in:
@@ -159,7 +159,8 @@ async def cancel_arrival(
|
|||||||
current_user: User = Depends(get_current_operator_user)
|
current_user: User = Depends(get_current_operator_user)
|
||||||
):
|
):
|
||||||
"""Cancel an arrival record"""
|
"""Cancel an arrival record"""
|
||||||
arrival = crud_arrival.cancel(db, arrival_id=arrival_id)
|
client_ip = get_client_ip(request)
|
||||||
|
arrival = crud_arrival.cancel(db, arrival_id=arrival_id, user=current_user.username, user_ip=client_ip)
|
||||||
if not arrival:
|
if not arrival:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ async def create_user(
|
|||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Username already registered"
|
detail="Username already registered"
|
||||||
)
|
)
|
||||||
user = crud_user.create(db, obj_in=user_in)
|
user = crud_user.create(db, obj_in=user_in, admin_user=current_user.username)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ async def update_user(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="User not found"
|
detail="User not found"
|
||||||
)
|
)
|
||||||
user = crud_user.update(db, db_obj=user, obj_in=user_in)
|
user = crud_user.update(db, db_obj=user, obj_in=user_in, admin_user=current_user.username)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -123,5 +123,5 @@ async def change_user_password(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="User not found"
|
detail="User not found"
|
||||||
)
|
)
|
||||||
user = crud_user.change_password(db, db_obj=user, new_password=password_data.password)
|
user = crud_user.change_password(db, db_obj=user, new_password=password_data.password, admin_user=current_user.username)
|
||||||
return user
|
return user
|
||||||
@@ -64,7 +64,8 @@ async def create_circuit(
|
|||||||
detail="Cannot provide both local_flight_id and arrival_id"
|
detail="Cannot provide both local_flight_id and arrival_id"
|
||||||
)
|
)
|
||||||
|
|
||||||
circuit = crud_circuit.create(db, obj_in=circuit_in)
|
client_ip = get_client_ip(request)
|
||||||
|
circuit = crud_circuit.create(db, obj_in=circuit_in, user=current_user.username, user_ip=client_ip)
|
||||||
|
|
||||||
# Send real-time update via WebSocket
|
# Send real-time update via WebSocket
|
||||||
if hasattr(request.app.state, 'connection_manager'):
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
@@ -116,6 +117,7 @@ async def update_circuit(
|
|||||||
|
|
||||||
@router.delete("/{circuit_id}")
|
@router.delete("/{circuit_id}")
|
||||||
async def delete_circuit(
|
async def delete_circuit(
|
||||||
|
request: Request,
|
||||||
circuit_id: int,
|
circuit_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_operator_user)
|
current_user: User = Depends(get_current_operator_user)
|
||||||
@@ -127,5 +129,6 @@ async def delete_circuit(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="Circuit record not found"
|
detail="Circuit record not found"
|
||||||
)
|
)
|
||||||
crud_circuit.delete(db, circuit_id=circuit_id)
|
client_ip = get_client_ip(request)
|
||||||
|
crud_circuit.delete(db, circuit_id=circuit_id, user=current_user.username, user_ip=client_ip)
|
||||||
return {"detail": "Circuit record deleted"}
|
return {"detail": "Circuit record deleted"}
|
||||||
|
|||||||
@@ -159,7 +159,8 @@ async def cancel_departure(
|
|||||||
current_user: User = Depends(get_current_operator_user)
|
current_user: User = Depends(get_current_operator_user)
|
||||||
):
|
):
|
||||||
"""Cancel a departure record"""
|
"""Cancel a departure record"""
|
||||||
departure = crud_departure.cancel(db, departure_id=departure_id)
|
client_ip = get_client_ip(request)
|
||||||
|
departure = crud_departure.cancel(db, departure_id=departure_id, user=current_user.username, user_ip=client_ip)
|
||||||
if not departure:
|
if not departure:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
|||||||
@@ -4,11 +4,79 @@ from app.api import deps
|
|||||||
from app.crud.crud_journal import journal
|
from app.crud.crud_journal import journal
|
||||||
from app.models.journal import EntityType
|
from app.models.journal import EntityType
|
||||||
from app.schemas.journal import JournalEntryResponse, EntityJournalResponse
|
from app.schemas.journal import JournalEntryResponse, EntityJournalResponse
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
router = APIRouter(tags=["journal"])
|
router = APIRouter(tags=["journal"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search/all", response_model=List[JournalEntryResponse])
|
||||||
|
async def search_journal(
|
||||||
|
date_from: Optional[date] = None,
|
||||||
|
date_to: Optional[date] = None,
|
||||||
|
entity_type: Optional[str] = None,
|
||||||
|
entity_id: Optional[int] = None,
|
||||||
|
user: Optional[str] = None,
|
||||||
|
limit: int = 500,
|
||||||
|
db: Session = Depends(deps.get_db),
|
||||||
|
current_user = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Search journal entries with optional filters.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- date_from: Filter entries from this date (YYYY-MM-DD)
|
||||||
|
- date_to: Filter entries until this date (YYYY-MM-DD)
|
||||||
|
- entity_type: Filter by entity type (PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE, OVERFLIGHT, CIRCUIT, USER)
|
||||||
|
- entity_id: Filter by specific entity ID
|
||||||
|
- user: Filter by user who created the entry
|
||||||
|
- limit: Maximum number of entries to return (default 500, max 5000)
|
||||||
|
|
||||||
|
All filters are optional and can be combined.
|
||||||
|
Returns entries in reverse chronological order (newest first).
|
||||||
|
"""
|
||||||
|
if limit > 5000:
|
||||||
|
limit = 5000
|
||||||
|
|
||||||
|
# Validate entity_type if provided
|
||||||
|
if entity_type:
|
||||||
|
try:
|
||||||
|
EntityType[entity_type.upper()]
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid entity_type. Must be one of: {', '.join([e.value for e in EntityType])}"
|
||||||
|
)
|
||||||
|
|
||||||
|
entries = journal.search_entries(
|
||||||
|
db,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
user=user,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/{username}", response_model=List[JournalEntryResponse])
|
||||||
|
async def get_user_journal(
|
||||||
|
username: str,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Session = Depends(deps.get_db),
|
||||||
|
current_user = Depends(deps.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all journal entries created by a specific user.
|
||||||
|
|
||||||
|
This endpoint is read-only and returns entries in reverse chronological order.
|
||||||
|
"""
|
||||||
|
entries = journal.get_user_journal(db, username, limit=limit)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{entity_type}/{entity_id}", response_model=EntityJournalResponse)
|
@router.get("/{entity_type}/{entity_id}", response_model=EntityJournalResponse)
|
||||||
async def get_entity_journal(
|
async def get_entity_journal(
|
||||||
entity_type: str,
|
entity_type: str,
|
||||||
@@ -45,19 +113,3 @@ async def get_entity_journal(
|
|||||||
entries=entries,
|
entries=entries,
|
||||||
total_entries=len(entries)
|
total_entries=len(entries)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/user/{username}", response_model=List[JournalEntryResponse])
|
|
||||||
async def get_user_journal(
|
|
||||||
username: str,
|
|
||||||
limit: int = 100,
|
|
||||||
db: Session = Depends(deps.get_db),
|
|
||||||
current_user = Depends(deps.get_current_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get all journal entries created by a specific user.
|
|
||||||
|
|
||||||
This endpoint is read-only and returns entries in reverse chronological order.
|
|
||||||
"""
|
|
||||||
entries = journal.get_user_journal(db, username, limit=limit)
|
|
||||||
return entries
|
|
||||||
|
|||||||
@@ -160,7 +160,8 @@ async def cancel_local_flight(
|
|||||||
current_user: User = Depends(get_current_operator_user)
|
current_user: User = Depends(get_current_operator_user)
|
||||||
):
|
):
|
||||||
"""Cancel a local flight record"""
|
"""Cancel a local flight record"""
|
||||||
flight = crud_local_flight.cancel(db, flight_id=flight_id)
|
client_ip = get_client_ip(request)
|
||||||
|
flight = crud_local_flight.cancel(db, flight_id=flight_id, user=current_user.username, user_ip=client_ip)
|
||||||
if not flight:
|
if not flight:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ async def public_book_local_flight(
|
|||||||
notes=flight_in.notes,
|
notes=flight_in.notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
flight = crud_local_flight.create(db, obj_in=flight_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC")
|
flight = crud_local_flight.create(db, obj_in=flight_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC", user_ip=request.client.host if request.client else None)
|
||||||
|
|
||||||
# Update with submission source and pilot email
|
# Update with submission source and pilot email
|
||||||
db.query(type(flight)).filter(type(flight).id == flight.id).update({
|
db.query(type(flight)).filter(type(flight).id == flight.id).update({
|
||||||
@@ -98,7 +98,7 @@ async def public_record_circuit(
|
|||||||
circuit_timestamp=circuit_in.circuit_timestamp,
|
circuit_timestamp=circuit_in.circuit_timestamp,
|
||||||
)
|
)
|
||||||
|
|
||||||
circuit = crud_circuit.create(db, obj_in=circuit_create)
|
circuit = crud_circuit.create(db, obj_in=circuit_create, user="PUBLIC_PILOT", user_ip=request.client.host if request.client else None)
|
||||||
|
|
||||||
# Send real-time update via WebSocket
|
# Send real-time update via WebSocket
|
||||||
if hasattr(request.app.state, 'connection_manager'):
|
if hasattr(request.app.state, 'connection_manager'):
|
||||||
@@ -136,7 +136,7 @@ async def public_book_departure(
|
|||||||
notes=departure_in.notes,
|
notes=departure_in.notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
departure = crud_departure.create(db, obj_in=departure_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC")
|
departure = crud_departure.create(db, obj_in=departure_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC", user_ip=request.client.host if request.client else None)
|
||||||
|
|
||||||
# Update with pilot email (submitted_via is already set in create method)
|
# Update with pilot email (submitted_via is already set in create method)
|
||||||
db.query(type(departure)).filter(type(departure).id == departure.id).update({
|
db.query(type(departure)).filter(type(departure).id == departure.id).update({
|
||||||
@@ -181,7 +181,7 @@ async def public_book_arrival(
|
|||||||
notes=arrival_in.notes,
|
notes=arrival_in.notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
arrival = crud_arrival.create(db, obj_in=arrival_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC")
|
arrival = crud_arrival.create(db, obj_in=arrival_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC", user_ip=request.client.host if request.client else None)
|
||||||
|
|
||||||
# Update with pilot email
|
# Update with pilot email
|
||||||
db.query(type(arrival)).filter(type(arrival).id == arrival.id).update({
|
db.query(type(arrival)).filter(type(arrival).id == arrival.id).update({
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class CRUDArrival:
|
|||||||
)
|
)
|
||||||
).order_by(Arrival.created_dt).all()
|
).order_by(Arrival.created_dt).all()
|
||||||
|
|
||||||
def create(self, db: Session, obj_in: ArrivalCreate, created_by: str, submitted_via: str = "ADMIN") -> Arrival:
|
def create(self, db: Session, obj_in: ArrivalCreate, created_by: str, submitted_via: str = "ADMIN", user_ip: Optional[str] = None) -> Arrival:
|
||||||
from app.models.arrival import SubmissionSource
|
from app.models.arrival import SubmissionSource
|
||||||
|
|
||||||
# Set initial status based on submission source
|
# Set initial status based on submission source
|
||||||
@@ -76,6 +76,17 @@ class CRUDArrival:
|
|||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log creation in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.ARRIVAL,
|
||||||
|
db_obj.id,
|
||||||
|
f"Arrival created: {db_obj.registration}",
|
||||||
|
created_by,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
def update(self, db: Session, db_obj: Arrival, obj_in: ArrivalUpdate, user: str = "system", user_ip: Optional[str] = None) -> Arrival:
|
def update(self, db: Session, db_obj: Arrival, obj_in: ArrivalUpdate, user: str = "system", user_ip: Optional[str] = None) -> Arrival:
|
||||||
@@ -156,15 +167,27 @@ class CRUDArrival:
|
|||||||
|
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
def cancel(self, db: Session, arrival_id: int) -> Optional[Arrival]:
|
def cancel(self, db: Session, arrival_id: int, user: str = "system", user_ip: Optional[str] = None) -> Optional[Arrival]:
|
||||||
db_obj = self.get(db, arrival_id)
|
db_obj = self.get(db, arrival_id)
|
||||||
if not db_obj:
|
if not db_obj:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
old_status = db_obj.status
|
||||||
db_obj.status = ArrivalStatus.CANCELLED
|
db_obj.status = ArrivalStatus.CANCELLED
|
||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log cancellation in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.ARRIVAL,
|
||||||
|
arrival_id,
|
||||||
|
f"Status changed from {old_status.value} to CANCELLED",
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from sqlalchemy import desc
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.models.circuit import Circuit
|
from app.models.circuit import Circuit
|
||||||
from app.schemas.circuit import CircuitCreate, CircuitUpdate
|
from app.schemas.circuit import CircuitCreate, CircuitUpdate
|
||||||
|
from app.models.journal import EntityType
|
||||||
|
from app.crud.crud_journal import journal
|
||||||
|
|
||||||
|
|
||||||
class CRUDCircuit:
|
class CRUDCircuit:
|
||||||
@@ -30,7 +32,7 @@ class CRUDCircuit:
|
|||||||
) -> List[Circuit]:
|
) -> List[Circuit]:
|
||||||
return db.query(Circuit).order_by(desc(Circuit.created_at)).offset(skip).limit(limit).all()
|
return db.query(Circuit).order_by(desc(Circuit.created_at)).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
def create(self, db: Session, obj_in: CircuitCreate) -> Circuit:
|
def create(self, db: Session, obj_in: CircuitCreate, user: str = "system", user_ip: Optional[str] = None) -> Circuit:
|
||||||
db_obj = Circuit(
|
db_obj = Circuit(
|
||||||
local_flight_id=obj_in.local_flight_id,
|
local_flight_id=obj_in.local_flight_id,
|
||||||
arrival_id=obj_in.arrival_id,
|
arrival_id=obj_in.arrival_id,
|
||||||
@@ -39,22 +41,74 @@ class CRUDCircuit:
|
|||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log circuit creation in journal
|
||||||
|
# Use LOCAL_FLIGHT entity type if local_flight_id exists, otherwise ARRIVAL
|
||||||
|
entity_type = EntityType.LOCAL_FLIGHT if obj_in.local_flight_id else EntityType.ARRIVAL
|
||||||
|
entity_id = obj_in.local_flight_id if obj_in.local_flight_id else obj_in.arrival_id
|
||||||
|
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
f"Circuit recorded at {obj_in.circuit_timestamp.isoformat()}",
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
def update(self, db: Session, db_obj: Circuit, obj_in: CircuitUpdate) -> Circuit:
|
def update(self, db: Session, db_obj: Circuit, obj_in: CircuitUpdate, user: str = "system", user_ip: Optional[str] = None) -> Circuit:
|
||||||
obj_data = obj_in.dict(exclude_unset=True)
|
obj_data = obj_in.dict(exclude_unset=True)
|
||||||
|
changes = []
|
||||||
|
|
||||||
for field, value in obj_data.items():
|
for field, value in obj_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)
|
setattr(db_obj, field, value)
|
||||||
|
|
||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log changes in journal if any were made
|
||||||
|
if changes:
|
||||||
|
entity_type = EntityType.LOCAL_FLIGHT if db_obj.local_flight_id else EntityType.ARRIVAL
|
||||||
|
entity_id = db_obj.local_flight_id if db_obj.local_flight_id else db_obj.arrival_id
|
||||||
|
|
||||||
|
for change in changes:
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
f"Circuit: {change}",
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
def delete(self, db: Session, circuit_id: int) -> bool:
|
def delete(self, db: Session, circuit_id: int, user: str = "system", user_ip: Optional[str] = None) -> bool:
|
||||||
circuit = self.get(db, circuit_id)
|
circuit = self.get(db, circuit_id)
|
||||||
if circuit:
|
if circuit:
|
||||||
|
# Determine which entity this circuit belongs to
|
||||||
|
entity_type = EntityType.LOCAL_FLIGHT if circuit.local_flight_id else EntityType.ARRIVAL
|
||||||
|
entity_id = circuit.local_flight_id if circuit.local_flight_id else circuit.arrival_id
|
||||||
|
|
||||||
db.delete(circuit)
|
db.delete(circuit)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Log deletion in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
f"Circuit deleted (recorded at {circuit.circuit_timestamp.isoformat()})",
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class CRUDDeparture:
|
|||||||
)
|
)
|
||||||
).order_by(Departure.created_dt).all()
|
).order_by(Departure.created_dt).all()
|
||||||
|
|
||||||
def create(self, db: Session, obj_in: DepartureCreate, created_by: str, submitted_via: str = "ADMIN") -> Departure:
|
def create(self, db: Session, obj_in: DepartureCreate, created_by: str, submitted_via: str = "ADMIN", user_ip: Optional[str] = None) -> Departure:
|
||||||
from app.models.departure import SubmissionSource
|
from app.models.departure import SubmissionSource
|
||||||
|
|
||||||
# Set initial status based on submission source
|
# Set initial status based on submission source
|
||||||
@@ -68,6 +68,17 @@ class CRUDDeparture:
|
|||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log creation in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.DEPARTURE,
|
||||||
|
db_obj.id,
|
||||||
|
f"Departure created: {db_obj.registration}",
|
||||||
|
created_by,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
def update(self, db: Session, db_obj: Departure, obj_in: DepartureUpdate, user: str = "system", user_ip: Optional[str] = None) -> Departure:
|
def update(self, db: Session, db_obj: Departure, obj_in: DepartureUpdate, user: str = "system", user_ip: Optional[str] = None) -> Departure:
|
||||||
@@ -150,15 +161,27 @@ class CRUDDeparture:
|
|||||||
|
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
def cancel(self, db: Session, departure_id: int) -> Optional[Departure]:
|
def cancel(self, db: Session, departure_id: int, user: str = "system", user_ip: Optional[str] = None) -> Optional[Departure]:
|
||||||
db_obj = self.get(db, departure_id)
|
db_obj = self.get(db, departure_id)
|
||||||
if not db_obj:
|
if not db_obj:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
old_status = db_obj.status
|
||||||
db_obj.status = DepartureStatus.CANCELLED
|
db_obj.status = DepartureStatus.CANCELLED
|
||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log cancellation in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.DEPARTURE,
|
||||||
|
departure_id,
|
||||||
|
f"Status changed from {old_status.value} to CANCELLED",
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_, func, desc
|
||||||
from app.models.journal import JournalEntry, EntityType
|
from app.models.journal import JournalEntry, EntityType
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
|
||||||
class CRUDJournal:
|
class CRUDJournal:
|
||||||
@@ -58,6 +59,41 @@ class CRUDJournal:
|
|||||||
JournalEntry.user == user
|
JournalEntry.user == user
|
||||||
).order_by(JournalEntry.entry_dt.desc()).limit(limit).all()
|
).order_by(JournalEntry.entry_dt.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
def search_entries(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
date_from: Optional[date] = None,
|
||||||
|
date_to: Optional[date] = None,
|
||||||
|
entity_type: Optional[str] = None,
|
||||||
|
entity_id: Optional[int] = None,
|
||||||
|
user: Optional[str] = None,
|
||||||
|
limit: int = 500
|
||||||
|
) -> List[JournalEntry]:
|
||||||
|
"""Search journal entries with optional filters."""
|
||||||
|
query = db.query(JournalEntry)
|
||||||
|
|
||||||
|
# Apply date filters
|
||||||
|
if date_from:
|
||||||
|
query = query.filter(func.date(JournalEntry.entry_dt) >= date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query = query.filter(func.date(JournalEntry.entry_dt) <= date_to)
|
||||||
|
|
||||||
|
# Apply entity type filter
|
||||||
|
if entity_type:
|
||||||
|
query = query.filter(JournalEntry.entity_type == entity_type.upper())
|
||||||
|
|
||||||
|
# Apply entity ID filter
|
||||||
|
if entity_id:
|
||||||
|
query = query.filter(JournalEntry.entity_id == entity_id)
|
||||||
|
|
||||||
|
# Apply user filter
|
||||||
|
if user:
|
||||||
|
query = query.filter(JournalEntry.user == user)
|
||||||
|
|
||||||
|
# Order by date descending (newest first) and apply limit
|
||||||
|
return query.order_by(JournalEntry.entry_dt.desc()).limit(limit).all()
|
||||||
|
|
||||||
# Convenience methods for backward compatibility with PPR journal
|
# Convenience methods for backward compatibility with PPR journal
|
||||||
def log_ppr_change(
|
def log_ppr_change(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ class CRUDLocalFlight:
|
|||||||
)
|
)
|
||||||
).order_by(LocalFlight.created_dt).all()
|
).order_by(LocalFlight.created_dt).all()
|
||||||
|
|
||||||
def create(self, db: Session, obj_in: LocalFlightCreate, created_by: str, submitted_via: str = "ADMIN") -> LocalFlight:
|
def create(self, db: Session, obj_in: LocalFlightCreate, created_by: str, submitted_via: str = "ADMIN", user_ip: Optional[str] = None) -> LocalFlight:
|
||||||
from app.models.local_flight import SubmissionSource
|
from app.models.local_flight import SubmissionSource
|
||||||
|
|
||||||
# Set initial status based on submission source
|
# Set initial status based on submission source
|
||||||
@@ -102,6 +102,17 @@ class CRUDLocalFlight:
|
|||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log creation in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.LOCAL_FLIGHT,
|
||||||
|
db_obj.id,
|
||||||
|
f"Local flight created: {db_obj.registration} ({db_obj.flight_type.value})",
|
||||||
|
created_by,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
|
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
def update(self, db: Session, db_obj: LocalFlight, obj_in: LocalFlightUpdate, user: str = "system", user_ip: Optional[str] = None) -> LocalFlight:
|
def update(self, db: Session, db_obj: LocalFlight, obj_in: LocalFlightUpdate, user: str = "system", user_ip: Optional[str] = None) -> LocalFlight:
|
||||||
@@ -201,13 +212,24 @@ class CRUDLocalFlight:
|
|||||||
|
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
def cancel(self, db: Session, flight_id: int) -> Optional[LocalFlight]:
|
def cancel(self, db: Session, flight_id: int, user: str = "system", user_ip: Optional[str] = None) -> Optional[LocalFlight]:
|
||||||
db_obj = self.get(db, flight_id)
|
db_obj = self.get(db, flight_id)
|
||||||
if db_obj:
|
if db_obj:
|
||||||
|
old_status = db_obj.status
|
||||||
db_obj.status = LocalFlightStatus.CANCELLED
|
db_obj.status = LocalFlightStatus.CANCELLED
|
||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log cancellation in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.LOCAL_FLIGHT,
|
||||||
|
flight_id,
|
||||||
|
f"Status changed from {old_status.value} to CANCELLED",
|
||||||
|
user,
|
||||||
|
user_ip
|
||||||
|
)
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from sqlalchemy.orm import Session
|
|||||||
from app.models.ppr import User
|
from app.models.ppr import User
|
||||||
from app.schemas.ppr import UserCreate, UserUpdate
|
from app.schemas.ppr import UserCreate, UserUpdate
|
||||||
from app.core.security import get_password_hash, verify_password
|
from app.core.security import get_password_hash, verify_password
|
||||||
|
from app.models.journal import EntityType
|
||||||
|
from app.crud.crud_journal import journal
|
||||||
|
|
||||||
|
|
||||||
class CRUDUser:
|
class CRUDUser:
|
||||||
@@ -15,7 +17,7 @@ class CRUDUser:
|
|||||||
def get_multi(self, db: Session, skip: int = 0, limit: int = 100) -> List[User]:
|
def get_multi(self, db: Session, skip: int = 0, limit: int = 100) -> List[User]:
|
||||||
return db.query(User).offset(skip).limit(limit).all()
|
return db.query(User).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
def create(self, db: Session, obj_in: UserCreate) -> User:
|
def create(self, db: Session, obj_in: UserCreate, admin_user: str = "system") -> User:
|
||||||
hashed_password = get_password_hash(obj_in.password)
|
hashed_password = get_password_hash(obj_in.password)
|
||||||
db_obj = User(
|
db_obj = User(
|
||||||
username=obj_in.username,
|
username=obj_in.username,
|
||||||
@@ -25,17 +27,46 @@ class CRUDUser:
|
|||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log user creation in journal
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.USER,
|
||||||
|
db_obj.id,
|
||||||
|
f"User created: {obj_in.username} with role {obj_in.role}",
|
||||||
|
admin_user,
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
def update(self, db: Session, db_obj: User, obj_in: UserUpdate) -> User:
|
def update(self, db: Session, db_obj: User, obj_in: UserUpdate, admin_user: str = "system") -> User:
|
||||||
update_data = obj_in.dict(exclude_unset=True)
|
update_data = obj_in.dict(exclude_unset=True)
|
||||||
|
changes = []
|
||||||
if "password" in update_data:
|
if "password" in update_data:
|
||||||
update_data["password"] = get_password_hash(update_data["password"])
|
update_data["password"] = get_password_hash(update_data["password"])
|
||||||
|
changes.append("password changed")
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
|
old_value = getattr(db_obj, field)
|
||||||
|
if field == "password" or old_value != value:
|
||||||
|
if field != "password": # Don't log actual password values
|
||||||
|
changes.append(f"{field} changed from '{old_value}' to '{value}'")
|
||||||
setattr(db_obj, field, value)
|
setattr(db_obj, field, value)
|
||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log user update in journal
|
||||||
|
if changes:
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.USER,
|
||||||
|
db_obj.id,
|
||||||
|
"; ".join(changes),
|
||||||
|
admin_user,
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
def authenticate(self, db: Session, username: str, password: str) -> Optional[User]:
|
def authenticate(self, db: Session, username: str, password: str) -> Optional[User]:
|
||||||
@@ -50,13 +81,24 @@ class CRUDUser:
|
|||||||
# For future use if we add user status
|
# For future use if we add user status
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def change_password(self, db: Session, db_obj: User, new_password: str) -> User:
|
def change_password(self, db: Session, db_obj: User, new_password: str, admin_user: str = "system") -> User:
|
||||||
"""Change a user's password (typically used by admins to reset another user's password)"""
|
"""Change a user's password (typically used by admins to reset another user's password)"""
|
||||||
hashed_password = get_password_hash(new_password)
|
hashed_password = get_password_hash(new_password)
|
||||||
db_obj.password = hashed_password
|
db_obj.password = hashed_password
|
||||||
db.add(db_obj)
|
db.add(db_obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
|
|
||||||
|
# Log password change in journal (security audit)
|
||||||
|
journal.log_change(
|
||||||
|
db,
|
||||||
|
EntityType.USER,
|
||||||
|
db_obj.id,
|
||||||
|
f"Password changed by {admin_user}",
|
||||||
|
admin_user,
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class EntityType(str, PyEnum):
|
|||||||
ARRIVAL = "ARRIVAL"
|
ARRIVAL = "ARRIVAL"
|
||||||
DEPARTURE = "DEPARTURE"
|
DEPARTURE = "DEPARTURE"
|
||||||
OVERFLIGHT = "OVERFLIGHT"
|
OVERFLIGHT = "OVERFLIGHT"
|
||||||
|
CIRCUIT = "CIRCUIT"
|
||||||
|
USER = "USER"
|
||||||
|
|
||||||
|
|
||||||
class JournalEntry(Base):
|
class JournalEntry(Base):
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
<div class="dropdown-menu" id="adminDropdownMenu">
|
<div class="dropdown-menu" id="adminDropdownMenu">
|
||||||
<a href="#" onclick="window.location.href = '/atc'">🎛️ ATC View</a>
|
<a href="#" onclick="window.location.href = '/atc'">🎛️ ATC View</a>
|
||||||
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
|
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
|
||||||
|
<a href="#" onclick="window.location.href = '/journal'">📔 Journal Log</a>
|
||||||
<a href="#" onclick="openUserAircraftModal(); closeAdminDropdown()" id="user-aircraft-dropdown">✈️ User Aircraft</a>
|
<a href="#" onclick="openUserAircraftModal(); closeAdminDropdown()" id="user-aircraft-dropdown">✈️ User Aircraft</a>
|
||||||
<a href="#" onclick="openUserManagementModal(); closeAdminDropdown()" id="user-management-dropdown" style="display: none;">👥 User Management</a>
|
<a href="#" onclick="openUserManagementModal(); closeAdminDropdown()" id="user-management-dropdown" style="display: none;">👥 User Management</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
<div class="dropdown-menu" id="adminDropdownMenu">
|
<div class="dropdown-menu" id="adminDropdownMenu">
|
||||||
<a href="#" onclick="window.location.href = '/admin'">🏠 Admin View</a>
|
<a href="#" onclick="window.location.href = '/admin'">🏠 Admin View</a>
|
||||||
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
|
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
|
||||||
|
<a href="#" onclick="window.location.href = '/journal'">📔 Journal Log</a>
|
||||||
<a href="#" onclick="openUserAircraftModal(); closeAdminDropdown()" id="user-aircraft-dropdown">✈️ User Aircraft</a>
|
<a href="#" onclick="openUserAircraftModal(); closeAdminDropdown()" id="user-aircraft-dropdown">✈️ User Aircraft</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,752 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Journal - PPR System</title>
|
||||||
|
<link rel="stylesheet" href="admin.css">
|
||||||
|
<script src="config.js"></script>
|
||||||
|
<script src="lookups.js"></script>
|
||||||
|
<style>
|
||||||
|
.journal-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-filters label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-filters input,
|
||||||
|
.journal-filters select {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-buttons button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-apply {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-apply:hover {
|
||||||
|
background: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset {
|
||||||
|
background: #757575;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset:hover {
|
||||||
|
background: #616161;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-table {
|
||||||
|
background: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-table table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-table thead {
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-table th {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-table tbody tr:hover {
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-badge-ppr { background: #2196F3; }
|
||||||
|
.entity-badge-local_flight { background: #FF9800; }
|
||||||
|
.entity-badge-arrival { background: #4CAF50; }
|
||||||
|
.entity-badge-departure { background: #9C27B0; }
|
||||||
|
.entity-badge-overflight { background: #00BCD4; }
|
||||||
|
.entity-badge-circuit { background: #FFC107; }
|
||||||
|
.entity-badge-user { background: #795548; }
|
||||||
|
|
||||||
|
.entry-text {
|
||||||
|
color: #555;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-datetime {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-user {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-ip {
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .stat-label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button.active {
|
||||||
|
background: #2196F3;
|
||||||
|
color: white;
|
||||||
|
border-color: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 4px solid #f3f3f3;
|
||||||
|
border-top: 4px solid #2196F3;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-export {
|
||||||
|
background: #2196F3;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-export:hover {
|
||||||
|
background: #1976D2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="top-bar">
|
||||||
|
<div class="title">
|
||||||
|
<h1>📔 Journal Log</h1>
|
||||||
|
</div>
|
||||||
|
<div class="menu-buttons">
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-warning dropdown-toggle" id="adminDropdownBtn">
|
||||||
|
⚙️ Menu
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu" id="adminDropdownMenu">
|
||||||
|
<a href="#" onclick="window.location.href = '/admin'">📋 Admin</a>
|
||||||
|
<a href="#" onclick="window.location.href = '/atc'">🎛️ ATC View</a>
|
||||||
|
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="user-info">
|
||||||
|
Logged in as: <span id="current-user">Loading...</span> |
|
||||||
|
<a href="#" onclick="logout()" style="color: white;">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="journal-filters">
|
||||||
|
<label>
|
||||||
|
Date From:
|
||||||
|
<input type="date" id="dateFrom">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Date To:
|
||||||
|
<input type="date" id="dateTo">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Entity Type:
|
||||||
|
<select id="entityType">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="PPR">PPR</option>
|
||||||
|
<option value="LOCAL_FLIGHT">Local Flight</option>
|
||||||
|
<option value="ARRIVAL">Arrival</option>
|
||||||
|
<option value="DEPARTURE">Departure</option>
|
||||||
|
<option value="OVERFLIGHT">Overflight</option>
|
||||||
|
<option value="CIRCUIT">Circuit</option>
|
||||||
|
<option value="USER">User</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
User:
|
||||||
|
<input type="text" id="filterUser" placeholder="e.g., john_doe">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Entity ID:
|
||||||
|
<input type="number" id="entityId" placeholder="Optional">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Search Text:
|
||||||
|
<input type="text" id="searchText" placeholder="Search in entries...">
|
||||||
|
</label>
|
||||||
|
<div class="filter-buttons">
|
||||||
|
<button class="btn-apply" onclick="applyFilters()">🔍 Search</button>
|
||||||
|
<button class="btn-reset" onclick="resetFilters()">↻ Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics -->
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="totalEntries">0</div>
|
||||||
|
<div class="stat-label">Total Entries</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="uniqueUsers">0</div>
|
||||||
|
<div class="stat-label">Unique Users</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="dateRange">-</div>
|
||||||
|
<div class="stat-label">Date Range</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export -->
|
||||||
|
<div class="export-section">
|
||||||
|
<button class="btn-export" onclick="exportToCSV()">📥 Export as CSV</button>
|
||||||
|
<button class="btn-export" onclick="exportToJSON()">📥 Export as JSON</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Journal Table -->
|
||||||
|
<div class="journal-table">
|
||||||
|
<div id="journal-loading" class="loading" style="display: none;">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
Loading journal entries...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="journal-content" style="display: none;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date/Time</th>
|
||||||
|
<th>Entity Type</th>
|
||||||
|
<th>Entity ID</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Entry</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="journal-table-body">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="journal-no-data" class="no-data" style="display: none;">
|
||||||
|
<h3>No Journal Entries Found</h3>
|
||||||
|
<p>Try adjusting your filters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="journal-error" class="no-data" style="display: none; color: #d32f2f;">
|
||||||
|
<h3>⚠️ Error Loading Journal</h3>
|
||||||
|
<p id="error-message"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div id="pagination" class="pagination" style="display: none;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_BASE = window.PPR_CONFIG.apiBase;
|
||||||
|
let allEntries = [];
|
||||||
|
let filteredEntries = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
const entriesPerPage = 50;
|
||||||
|
let accessToken = null;
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initializeAuth();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function initializeAuth() {
|
||||||
|
// Try to get cached token
|
||||||
|
const cachedToken = localStorage.getItem('ppr_access_token');
|
||||||
|
const cachedUser = localStorage.getItem('ppr_username');
|
||||||
|
const tokenExpiry = localStorage.getItem('ppr_token_expiry');
|
||||||
|
|
||||||
|
if (cachedToken && cachedUser && tokenExpiry) {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
if (now < parseInt(tokenExpiry)) {
|
||||||
|
// Token is still valid
|
||||||
|
accessToken = cachedToken;
|
||||||
|
currentUser = cachedUser;
|
||||||
|
document.getElementById('current-user').textContent = cachedUser;
|
||||||
|
setDefaultDates();
|
||||||
|
loadJournalEntries();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid cached token, show error or redirect to login
|
||||||
|
showError('Session expired or not authenticated. Please log in through the admin page.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDefaultDates() {
|
||||||
|
const today = new Date();
|
||||||
|
const thirtyDaysAgo = new Date(today);
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
|
document.getElementById('dateFrom').valueAsDate = thirtyDaysAgo;
|
||||||
|
document.getElementById('dateTo').valueAsDate = today;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadJournalEntries() {
|
||||||
|
showLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!accessToken) {
|
||||||
|
showError('No authentication token available.');
|
||||||
|
showLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the new search endpoint with default date range
|
||||||
|
const dateFrom = document.getElementById('dateFrom').value;
|
||||||
|
const dateTo = document.getElementById('dateTo').value;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
date_from: dateFrom,
|
||||||
|
date_to: dateTo,
|
||||||
|
limit: 500
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/journal/search/all?${params}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
showError('Session expired. Please log in again through the admin page.');
|
||||||
|
showLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load journal entries: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
allEntries = await response.json();
|
||||||
|
|
||||||
|
showLoading(false);
|
||||||
|
updateStats();
|
||||||
|
applyFilters();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading journal:', error);
|
||||||
|
showError(error.message);
|
||||||
|
showLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
showLoading(true);
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
showError('No authentication token available. Please log in again.');
|
||||||
|
showLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFrom = document.getElementById('dateFrom').value;
|
||||||
|
const dateTo = document.getElementById('dateTo').value;
|
||||||
|
const entityType = document.getElementById('entityType').value;
|
||||||
|
const filterUser = document.getElementById('filterUser').value;
|
||||||
|
const entityId = document.getElementById('entityId').value;
|
||||||
|
const searchText = document.getElementById('searchText').value.toLowerCase();
|
||||||
|
|
||||||
|
// Build API request with filters
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (dateFrom) params.append('date_from', dateFrom);
|
||||||
|
if (dateTo) params.append('date_to', dateTo);
|
||||||
|
if (entityType) params.append('entity_type', entityType);
|
||||||
|
if (entityId) params.append('entity_id', entityId);
|
||||||
|
if (filterUser) params.append('user', filterUser);
|
||||||
|
params.append('limit', 500);
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/journal/search/all?${params}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error('Session expired. Please log in again through the admin page.');
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error(`Search failed: ${response.statusText}`);
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
filteredEntries = data;
|
||||||
|
|
||||||
|
// Apply client-side text search if any
|
||||||
|
if (searchText) {
|
||||||
|
filteredEntries = filteredEntries.filter(entry =>
|
||||||
|
entry.entry.toLowerCase().includes(searchText)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPage = 1;
|
||||||
|
displayEntries();
|
||||||
|
updatePagination();
|
||||||
|
showLoading(false);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error searching journal:', error);
|
||||||
|
showError(error.message);
|
||||||
|
showLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayEntries() {
|
||||||
|
const start = (currentPage - 1) * entriesPerPage;
|
||||||
|
const end = start + entriesPerPage;
|
||||||
|
const pageEntries = filteredEntries.slice(start, end);
|
||||||
|
|
||||||
|
if (pageEntries.length === 0) {
|
||||||
|
document.getElementById('journal-table-body').innerHTML = '';
|
||||||
|
document.getElementById('journal-content').style.display = 'none';
|
||||||
|
document.getElementById('journal-no-data').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableBody = document.getElementById('journal-table-body');
|
||||||
|
tableBody.innerHTML = pageEntries.map(entry => `
|
||||||
|
<tr>
|
||||||
|
<td class="entry-datetime">${formatDateTime(entry.entry_dt)}</td>
|
||||||
|
<td><span class="entity-type-badge entity-badge-${entry.entity_type.toLowerCase()}">${entry.entity_type}</span></td>
|
||||||
|
<td>${entry.entity_id}</td>
|
||||||
|
<td class="entry-user">${entry.user}</td>
|
||||||
|
<td class="entry-text">${escapeHtml(entry.entry)}</td>
|
||||||
|
<td class="entry-ip">${entry.ip || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
document.getElementById('journal-content').style.display = 'block';
|
||||||
|
document.getElementById('journal-no-data').style.display = 'none';
|
||||||
|
document.getElementById('journal-error').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePagination() {
|
||||||
|
const totalPages = Math.ceil(filteredEntries.length / entriesPerPage);
|
||||||
|
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
document.getElementById('pagination').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paginationDiv = document.getElementById('pagination');
|
||||||
|
paginationDiv.innerHTML = '';
|
||||||
|
|
||||||
|
if (currentPage > 1) {
|
||||||
|
const prevBtn = document.createElement('button');
|
||||||
|
prevBtn.textContent = '← Previous';
|
||||||
|
prevBtn.onclick = () => {
|
||||||
|
currentPage--;
|
||||||
|
displayEntries();
|
||||||
|
updatePagination();
|
||||||
|
};
|
||||||
|
paginationDiv.appendChild(prevBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show page numbers
|
||||||
|
const startPage = Math.max(1, currentPage - 2);
|
||||||
|
const endPage = Math.min(totalPages, currentPage + 2);
|
||||||
|
|
||||||
|
if (startPage > 1) {
|
||||||
|
const firstBtn = document.createElement('button');
|
||||||
|
firstBtn.textContent = '1';
|
||||||
|
firstBtn.onclick = () => {
|
||||||
|
currentPage = 1;
|
||||||
|
displayEntries();
|
||||||
|
updatePagination();
|
||||||
|
};
|
||||||
|
paginationDiv.appendChild(firstBtn);
|
||||||
|
|
||||||
|
if (startPage > 2) {
|
||||||
|
const dots = document.createElement('span');
|
||||||
|
dots.textContent = '...';
|
||||||
|
dots.style.padding = '0.5rem 0.5rem';
|
||||||
|
paginationDiv.appendChild(dots);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = i;
|
||||||
|
if (i === currentPage) btn.className = 'active';
|
||||||
|
btn.onclick = () => {
|
||||||
|
currentPage = i;
|
||||||
|
displayEntries();
|
||||||
|
updatePagination();
|
||||||
|
};
|
||||||
|
paginationDiv.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endPage < totalPages) {
|
||||||
|
if (endPage < totalPages - 1) {
|
||||||
|
const dots = document.createElement('span');
|
||||||
|
dots.textContent = '...';
|
||||||
|
dots.style.padding = '0.5rem 0.5rem';
|
||||||
|
paginationDiv.appendChild(dots);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastBtn = document.createElement('button');
|
||||||
|
lastBtn.textContent = totalPages;
|
||||||
|
lastBtn.onclick = () => {
|
||||||
|
currentPage = totalPages;
|
||||||
|
displayEntries();
|
||||||
|
updatePagination();
|
||||||
|
};
|
||||||
|
paginationDiv.appendChild(lastBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
const nextBtn = document.createElement('button');
|
||||||
|
nextBtn.textContent = 'Next →';
|
||||||
|
nextBtn.onclick = () => {
|
||||||
|
currentPage++;
|
||||||
|
displayEntries();
|
||||||
|
updatePagination();
|
||||||
|
};
|
||||||
|
paginationDiv.appendChild(nextBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
paginationDiv.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
document.getElementById('totalEntries').textContent = allEntries.length;
|
||||||
|
|
||||||
|
const uniqueUsers = new Set(allEntries.map(e => e.user)).size;
|
||||||
|
document.getElementById('uniqueUsers').textContent = uniqueUsers;
|
||||||
|
|
||||||
|
if (allEntries.length > 0) {
|
||||||
|
const dates = allEntries
|
||||||
|
.map(e => new Date(e.entry_dt))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
const earliest = formatDate(dates[0]);
|
||||||
|
const latest = formatDate(dates[dates.length - 1]);
|
||||||
|
document.getElementById('dateRange').textContent = `${earliest} to ${latest}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
setDefaultDates();
|
||||||
|
document.getElementById('entityType').value = '';
|
||||||
|
document.getElementById('filterUser').value = '';
|
||||||
|
document.getElementById('entityId').value = '';
|
||||||
|
document.getElementById('searchText').value = '';
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoading(show) {
|
||||||
|
document.getElementById('journal-loading').style.display = show ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
document.getElementById('error-message').textContent = message;
|
||||||
|
document.getElementById('journal-error').style.display = 'block';
|
||||||
|
document.getElementById('journal-content').style.display = 'none';
|
||||||
|
document.getElementById('journal-no-data').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportToCSV() {
|
||||||
|
let csv = 'Date/Time,Entity Type,Entity ID,User,Entry,IP Address\n';
|
||||||
|
|
||||||
|
filteredEntries.forEach(entry => {
|
||||||
|
const row = [
|
||||||
|
formatDateTime(entry.entry_dt),
|
||||||
|
entry.entity_type,
|
||||||
|
entry.entity_id,
|
||||||
|
entry.user,
|
||||||
|
`"${entry.entry.replace(/"/g, '""')}"`,
|
||||||
|
entry.ip || ''
|
||||||
|
];
|
||||||
|
csv += row.join(',') + '\n';
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadFile(csv, 'journal_export.csv', 'text/csv');
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportToJSON() {
|
||||||
|
const json = JSON.stringify(filteredEntries, null, 2);
|
||||||
|
downloadFile(json, 'journal_export.json', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(content, filename, type) {
|
||||||
|
const blob = new Blob([content], { type });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateString) {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const map = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
return text.replace(/[&<>"']/g, m => map[m]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('ppr_access_token');
|
||||||
|
localStorage.removeItem('ppr_username');
|
||||||
|
localStorage.removeItem('ppr_token_expiry');
|
||||||
|
window.location.href = '/admin';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -365,6 +365,9 @@
|
|||||||
<button class="btn btn-secondary" onclick="window.location.href='admin'">
|
<button class="btn btn-secondary" onclick="window.location.href='admin'">
|
||||||
← Back to Admin
|
← Back to Admin
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="window.location.href='journal'">
|
||||||
|
📔 Journal Log
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<h1 id="tower-title">📊 PPR Reports</h1>
|
<h1 id="tower-title">📊 PPR Reports</h1>
|
||||||
|
|||||||
Reference in New Issue
Block a user