Journaling improvements

This commit is contained in:
2026-04-03 03:57:20 -04:00
parent 2dce14507b
commit dee58e0aae
18 changed files with 1061 additions and 44 deletions
+2 -1
View File
@@ -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,
+3 -3
View File
@@ -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
+5 -2
View File
@@ -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"}
+2 -1
View File
@@ -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,
+69 -17
View File
@@ -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
+2 -1
View File
@@ -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,
+4 -4
View File
@@ -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({
+25 -2
View File
@@ -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
+57 -3
View File
@@ -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
+25 -2
View File
@@ -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
+37 -1
View File
@@ -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,
+24 -2
View File
@@ -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
+45 -3
View File
@@ -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
+2
View File
@@ -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):
+1
View File
@@ -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>
+1
View File
@@ -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>
+752
View File
@@ -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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
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>
+3
View File
@@ -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>