From 04edc636f8acf62fbd42d864cf3d831db39c215d Mon Sep 17 00:00:00 2001 From: HotSwapp <47397945+HotSwapp@users.noreply.github.com> Date: Fri, 8 Aug 2025 19:06:39 -0500 Subject: [PATCH] v2 --- app/api/auth.py | 20 + app/api/support.py | 452 ++++++++++++++++++++++ app/config.py | 2 +- app/main.py | 2 + app/models/__init__.py | 2 + app/models/support.py | 102 +++++ app/models/user.py | 1 + requirements.txt | 32 +- templates/admin.html | 722 +++++++++++++++++++++++++++++++++-- templates/base.html | 221 ++++++++++- templates/login.html | 34 +- templates/support_modal.html | 286 ++++++++++++++ 12 files changed, 1824 insertions(+), 52 deletions(-) create mode 100644 app/api/support.py create mode 100644 app/models/support.py create mode 100644 templates/support_modal.html diff --git a/app/api/auth.py b/app/api/auth.py index 59a58fa..7ada190 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -89,6 +89,26 @@ async def read_users_me(current_user: User = Depends(get_current_user)): return current_user +@router.post("/refresh", response_model=Token) +async def refresh_token( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Refresh access token for current user""" + # Update last login timestamp + current_user.last_login = datetime.utcnow() + db.commit() + + # Create new token with full expiration time + access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) + access_token = create_access_token( + data={"sub": current_user.username}, + expires_delta=access_token_expires + ) + + return {"access_token": access_token, "token_type": "bearer"} + + @router.get("/users", response_model=List[UserResponse]) async def list_users( db: Session = Depends(get_db), diff --git a/app/api/support.py b/app/api/support.py new file mode 100644 index 0000000..0175bfe --- /dev/null +++ b/app/api/support.py @@ -0,0 +1,452 @@ +""" +Support ticket API endpoints +""" +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import func, desc, and_, or_ +from datetime import datetime +import secrets + +from app.database.base import get_db +from app.models import User, SupportTicket, TicketResponse, TicketStatus, TicketPriority, TicketCategory +from app.auth.security import get_current_user, get_admin_user + +router = APIRouter() + +# Pydantic models for API +from pydantic import BaseModel, Field, EmailStr + + +class TicketCreate(BaseModel): + """Create new support ticket""" + subject: str = Field(..., min_length=5, max_length=200) + description: str = Field(..., min_length=10) + category: TicketCategory = TicketCategory.BUG_REPORT + priority: TicketPriority = TicketPriority.MEDIUM + contact_name: str = Field(..., min_length=1, max_length=100) + contact_email: EmailStr + current_page: Optional[str] = None + browser_info: Optional[str] = None + + +class TicketUpdate(BaseModel): + """Update existing ticket""" + subject: Optional[str] = Field(None, min_length=5, max_length=200) + description: Optional[str] = Field(None, min_length=10) + category: Optional[TicketCategory] = None + priority: Optional[TicketPriority] = None + status: Optional[TicketStatus] = None + assigned_to: Optional[int] = None + + +class ResponseCreate(BaseModel): + """Create ticket response""" + message: str = Field(..., min_length=1) + is_internal: bool = False + + +class TicketResponse(BaseModel): + """Ticket response model""" + id: int + ticket_id: int + message: str + is_internal: bool + author_name: Optional[str] + author_email: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + + +class TicketDetail(BaseModel): + """Detailed ticket information""" + id: int + ticket_number: str + subject: str + description: str + category: TicketCategory + priority: TicketPriority + status: TicketStatus + contact_name: str + contact_email: str + current_page: Optional[str] + browser_info: Optional[str] + ip_address: Optional[str] + created_at: datetime + updated_at: Optional[datetime] + resolved_at: Optional[datetime] + assigned_to: Optional[int] + assigned_admin_name: Optional[str] + submitter_name: Optional[str] + responses: List[TicketResponse] = [] + + class Config: + from_attributes = True + + +def generate_ticket_number() -> str: + """Generate unique ticket number like ST-2024-001""" + year = datetime.now().year + random_suffix = secrets.token_hex(2).upper() + return f"ST-{year}-{random_suffix}" + + +@router.post("/tickets", response_model=dict) +async def create_support_ticket( + ticket_data: TicketCreate, + request: Request, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(lambda: None) # Allow anonymous submissions +): + """Create new support ticket (public endpoint)""" + + # current_user is already set from dependency injection above + # No need to call get_current_user again + + # Generate unique ticket number + ticket_number = generate_ticket_number() + while db.query(SupportTicket).filter(SupportTicket.ticket_number == ticket_number).first(): + ticket_number = generate_ticket_number() + + # Get client info from request + client_ip = request.client.host + user_agent = request.headers.get("User-Agent", "") + + # Create ticket + new_ticket = SupportTicket( + ticket_number=ticket_number, + subject=ticket_data.subject, + description=ticket_data.description, + category=ticket_data.category, + priority=ticket_data.priority, + contact_name=ticket_data.contact_name, + contact_email=ticket_data.contact_email, + current_page=ticket_data.current_page, + browser_info=ticket_data.browser_info or user_agent, + ip_address=client_ip, + user_id=current_user.id if current_user else None, + status=TicketStatus.OPEN, + created_at=datetime.utcnow() + ) + + db.add(new_ticket) + db.commit() + db.refresh(new_ticket) + + return { + "message": "Support ticket created successfully", + "ticket_number": new_ticket.ticket_number, + "ticket_id": new_ticket.id, + "status": "created" + } + + +@router.get("/tickets", response_model=List[TicketDetail]) +async def list_tickets( + status: Optional[TicketStatus] = None, + priority: Optional[TicketPriority] = None, + category: Optional[TicketCategory] = None, + assigned_to_me: bool = False, + skip: int = 0, + limit: int = 50, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """List support tickets (admin only)""" + + query = db.query(SupportTicket).options( + joinedload(SupportTicket.submitter), + joinedload(SupportTicket.assigned_admin), + joinedload(SupportTicket.responses) + ) + + # Apply filters + if status: + query = query.filter(SupportTicket.status == status) + if priority: + query = query.filter(SupportTicket.priority == priority) + if category: + query = query.filter(SupportTicket.category == category) + if assigned_to_me: + query = query.filter(SupportTicket.assigned_to == current_user.id) + + tickets = query.order_by(desc(SupportTicket.created_at)).offset(skip).limit(limit).all() + + # Format response + result = [] + for ticket in tickets: + ticket_dict = { + "id": ticket.id, + "ticket_number": ticket.ticket_number, + "subject": ticket.subject, + "description": ticket.description, + "category": ticket.category, + "priority": ticket.priority, + "status": ticket.status, + "contact_name": ticket.contact_name, + "contact_email": ticket.contact_email, + "current_page": ticket.current_page, + "browser_info": ticket.browser_info, + "ip_address": ticket.ip_address, + "created_at": ticket.created_at, + "updated_at": ticket.updated_at, + "resolved_at": ticket.resolved_at, + "assigned_to": ticket.assigned_to, + "assigned_admin_name": f"{ticket.assigned_admin.first_name} {ticket.assigned_admin.last_name}" if ticket.assigned_admin else None, + "submitter_name": f"{ticket.submitter.first_name} {ticket.submitter.last_name}" if ticket.submitter else ticket.contact_name, + "responses": [ + { + "id": resp.id, + "ticket_id": resp.ticket_id, + "message": resp.message, + "is_internal": resp.is_internal, + "author_name": f"{resp.author.first_name} {resp.author.last_name}" if resp.author else resp.author_name, + "author_email": resp.author.email if resp.author else resp.author_email, + "created_at": resp.created_at + } + for resp in ticket.responses if not resp.is_internal # Only show public responses + ] + } + result.append(ticket_dict) + + return result + + +@router.get("/tickets/{ticket_id}", response_model=TicketDetail) +async def get_ticket( + ticket_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Get ticket details (admin only)""" + + ticket = db.query(SupportTicket).options( + joinedload(SupportTicket.submitter), + joinedload(SupportTicket.assigned_admin), + joinedload(SupportTicket.responses).joinedload(TicketResponse.author) + ).filter(SupportTicket.id == ticket_id).first() + + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ticket not found" + ) + + # Format response (include internal responses for admins) + return { + "id": ticket.id, + "ticket_number": ticket.ticket_number, + "subject": ticket.subject, + "description": ticket.description, + "category": ticket.category, + "priority": ticket.priority, + "status": ticket.status, + "contact_name": ticket.contact_name, + "contact_email": ticket.contact_email, + "current_page": ticket.current_page, + "browser_info": ticket.browser_info, + "ip_address": ticket.ip_address, + "created_at": ticket.created_at, + "updated_at": ticket.updated_at, + "resolved_at": ticket.resolved_at, + "assigned_to": ticket.assigned_to, + "assigned_admin_name": f"{ticket.assigned_admin.first_name} {ticket.assigned_admin.last_name}" if ticket.assigned_admin else None, + "submitter_name": f"{ticket.submitter.first_name} {ticket.submitter.last_name}" if ticket.submitter else ticket.contact_name, + "responses": [ + { + "id": resp.id, + "ticket_id": resp.ticket_id, + "message": resp.message, + "is_internal": resp.is_internal, + "author_name": f"{resp.author.first_name} {resp.author.last_name}" if resp.author else resp.author_name, + "author_email": resp.author.email if resp.author else resp.author_email, + "created_at": resp.created_at + } + for resp in ticket.responses + ] + } + + +@router.put("/tickets/{ticket_id}") +async def update_ticket( + ticket_id: int, + ticket_data: TicketUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Update ticket (admin only)""" + + ticket = db.query(SupportTicket).filter(SupportTicket.id == ticket_id).first() + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ticket not found" + ) + + # Track changes for audit + changes = {} + update_data = ticket_data.model_dump(exclude_unset=True) + + for field, value in update_data.items(): + if hasattr(ticket, field) and getattr(ticket, field) != value: + changes[field] = {"from": getattr(ticket, field), "to": value} + setattr(ticket, field, value) + + # Set resolved timestamp if status changed to resolved + if ticket_data.status == TicketStatus.RESOLVED and ticket.resolved_at is None: + ticket.resolved_at = datetime.utcnow() + changes["resolved_at"] = {"from": None, "to": ticket.resolved_at} + + ticket.updated_at = datetime.utcnow() + db.commit() + + # Log the update (audit logging can be added later) + # TODO: Add audit logging for ticket updates + + return {"message": "Ticket updated successfully"} + + +@router.post("/tickets/{ticket_id}/responses") +async def add_response( + ticket_id: int, + response_data: ResponseCreate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Add response to ticket (admin only)""" + + ticket = db.query(SupportTicket).filter(SupportTicket.id == ticket_id).first() + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ticket not found" + ) + + # Create response + response = TicketResponse( + ticket_id=ticket_id, + message=response_data.message, + is_internal=response_data.is_internal, + user_id=current_user.id, + created_at=datetime.utcnow() + ) + + db.add(response) + + # Update ticket timestamp + ticket.updated_at = datetime.utcnow() + + db.commit() + db.refresh(response) + + # Log the response (audit logging can be added later) + # TODO: Add audit logging for ticket responses + + return {"message": "Response added successfully", "response_id": response.id} + + +@router.get("/my-tickets", response_model=List[TicketDetail]) +async def get_my_tickets( + status: Optional[TicketStatus] = None, + skip: int = 0, + limit: int = 20, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Get current user's tickets""" + + query = db.query(SupportTicket).options( + joinedload(SupportTicket.responses) + ).filter(SupportTicket.user_id == current_user.id) + + if status: + query = query.filter(SupportTicket.status == status) + + tickets = query.order_by(desc(SupportTicket.created_at)).offset(skip).limit(limit).all() + + # Format response (exclude internal responses for regular users) + result = [] + for ticket in tickets: + ticket_dict = { + "id": ticket.id, + "ticket_number": ticket.ticket_number, + "subject": ticket.subject, + "description": ticket.description, + "category": ticket.category, + "priority": ticket.priority, + "status": ticket.status, + "contact_name": ticket.contact_name, + "contact_email": ticket.contact_email, + "current_page": ticket.current_page, + "browser_info": None, # Don't expose to user + "ip_address": None, # Don't expose to user + "created_at": ticket.created_at, + "updated_at": ticket.updated_at, + "resolved_at": ticket.resolved_at, + "assigned_to": None, # Don't expose to user + "assigned_admin_name": None, + "submitter_name": f"{current_user.first_name} {current_user.last_name}", + "responses": [ + { + "id": resp.id, + "ticket_id": resp.ticket_id, + "message": resp.message, + "is_internal": False, # Always show as public to user + "author_name": f"{resp.author.first_name} {resp.author.last_name}" if resp.author else "Support Team", + "author_email": None, # Don't expose admin emails + "created_at": resp.created_at + } + for resp in ticket.responses if not resp.is_internal # Only show public responses + ] + } + result.append(ticket_dict) + + return result + + +@router.get("/stats") +async def get_ticket_stats( + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """Get support ticket statistics (admin only)""" + + total_tickets = db.query(func.count(SupportTicket.id)).scalar() + open_tickets = db.query(func.count(SupportTicket.id)).filter( + SupportTicket.status == TicketStatus.OPEN + ).scalar() + in_progress_tickets = db.query(func.count(SupportTicket.id)).filter( + SupportTicket.status == TicketStatus.IN_PROGRESS + ).scalar() + resolved_tickets = db.query(func.count(SupportTicket.id)).filter( + SupportTicket.status == TicketStatus.RESOLVED + ).scalar() + + # Tickets by priority + high_priority = db.query(func.count(SupportTicket.id)).filter( + and_(SupportTicket.priority == TicketPriority.HIGH, SupportTicket.status != TicketStatus.RESOLVED) + ).scalar() + urgent_tickets = db.query(func.count(SupportTicket.id)).filter( + and_(SupportTicket.priority == TicketPriority.URGENT, SupportTicket.status != TicketStatus.RESOLVED) + ).scalar() + + # Recent tickets (last 7 days) + from datetime import timedelta + week_ago = datetime.utcnow() - timedelta(days=7) + recent_tickets = db.query(func.count(SupportTicket.id)).filter( + SupportTicket.created_at >= week_ago + ).scalar() + + return { + "total_tickets": total_tickets, + "open_tickets": open_tickets, + "in_progress_tickets": in_progress_tickets, + "resolved_tickets": resolved_tickets, + "high_priority_tickets": high_priority, + "urgent_tickets": urgent_tickets, + "recent_tickets": recent_tickets + } \ No newline at end of file diff --git a/app/config.py b/app/config.py index 5143149..12f2767 100644 --- a/app/config.py +++ b/app/config.py @@ -19,7 +19,7 @@ class Settings(BaseSettings): # Authentication secret_key: str = "your-secret-key-change-in-production" algorithm: str = "HS256" - access_token_expire_minutes: int = 30 + access_token_expire_minutes: int = 240 # 4 hours # Admin account settings admin_username: str = "admin" diff --git a/app/main.py b/app/main.py index 8bc3122..110fdda 100644 --- a/app/main.py +++ b/app/main.py @@ -45,6 +45,7 @@ from app.api.documents import router as documents_router from app.api.search import router as search_router from app.api.admin import router as admin_router from app.api.import_data import router as import_router +from app.api.support import router as support_router app.include_router(auth_router, prefix="/api/auth", tags=["authentication"]) app.include_router(customers_router, prefix="/api/customers", tags=["customers"]) @@ -54,6 +55,7 @@ app.include_router(documents_router, prefix="/api/documents", tags=["documents"] app.include_router(search_router, prefix="/api/search", tags=["search"]) app.include_router(admin_router, prefix="/api/admin", tags=["admin"]) app.include_router(import_router, tags=["import"]) +app.include_router(support_router, prefix="/api/support", tags=["support"]) @app.get("/", response_class=HTMLResponse) diff --git a/app/models/__init__.py b/app/models/__init__.py index dafc1e9..df6835b 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -9,6 +9,7 @@ from .ledger import Ledger from .qdro import QDRO from .audit import AuditLog, LoginAttempt from .additional import Deposit, Payment, FileNote, FormVariable, ReportVariable +from .support import SupportTicket, TicketResponse, TicketStatus, TicketPriority, TicketCategory from .pensions import ( Pension, PensionSchedule, MarriageHistory, DeathBenefit, SeparationAgreement, LifeTable, NumberTable @@ -23,6 +24,7 @@ __all__ = [ "BaseModel", "User", "Rolodex", "Phone", "File", "Ledger", "QDRO", "AuditLog", "LoginAttempt", "Deposit", "Payment", "FileNote", "FormVariable", "ReportVariable", + "SupportTicket", "TicketResponse", "TicketStatus", "TicketPriority", "TicketCategory", "Pension", "PensionSchedule", "MarriageHistory", "DeathBenefit", "SeparationAgreement", "LifeTable", "NumberTable", "Employee", "FileType", "FileStatus", "TransactionType", "TransactionCode", diff --git a/app/models/support.py b/app/models/support.py new file mode 100644 index 0000000..045ac4c --- /dev/null +++ b/app/models/support.py @@ -0,0 +1,102 @@ +""" +Support ticket models for help desk functionality +""" +from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Enum +from sqlalchemy.orm import relationship +from datetime import datetime +import enum + +from app.models.base import BaseModel + + +class TicketStatus(enum.Enum): + OPEN = "open" + IN_PROGRESS = "in_progress" + RESOLVED = "resolved" + CLOSED = "closed" + + +class TicketPriority(enum.Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + URGENT = "urgent" + + +class TicketCategory(enum.Enum): + BUG_REPORT = "bug_report" + QA_ISSUE = "qa_issue" + FEATURE_REQUEST = "feature_request" + DATABASE_ISSUE = "database_issue" + SYSTEM_ERROR = "system_error" + USER_ACCESS = "user_access" + PERFORMANCE = "performance" + DOCUMENTATION = "documentation" + CONFIGURATION = "configuration" + TESTING = "testing" + + +class SupportTicket(BaseModel): + """ + Support ticket for user help requests + """ + __tablename__ = "support_tickets" + + id = Column(Integer, primary_key=True, autoincrement=True) + ticket_number = Column(String(20), unique=True, nullable=False, index=True) # Auto-generated like ST-2024-001 + + # Ticket details + subject = Column(String(200), nullable=False) + description = Column(Text, nullable=False) + category = Column(Enum(TicketCategory), default=TicketCategory.BUG_REPORT) + priority = Column(Enum(TicketPriority), default=TicketPriority.MEDIUM) + status = Column(Enum(TicketStatus), default=TicketStatus.OPEN) + + # User information + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Null for anonymous submissions + contact_name = Column(String(100), nullable=False) + contact_email = Column(String(100), nullable=False) + + # System information (auto-detected) + current_page = Column(String(100)) # Which page they were on + browser_info = Column(String(200)) # User agent + ip_address = Column(String(45)) # IP address + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + resolved_at = Column(DateTime) + + # Admin assignment + assigned_to = Column(Integer, ForeignKey("users.id")) + + # Relationships + submitter = relationship("User", foreign_keys=[user_id], back_populates="submitted_tickets") + assigned_admin = relationship("User", foreign_keys=[assigned_to]) + responses = relationship("TicketResponse", back_populates="ticket", cascade="all, delete-orphan") + + +class TicketResponse(BaseModel): + """ + Responses/comments on support tickets + """ + __tablename__ = "ticket_responses" + + id = Column(Integer, primary_key=True, autoincrement=True) + ticket_id = Column(Integer, ForeignKey("support_tickets.id"), nullable=False) + + # Response details + message = Column(Text, nullable=False) + is_internal = Column(Boolean, default=False) # Internal admin notes vs public responses + + # Author information + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + author_name = Column(String(100)) # For non-user responses + author_email = Column(String(100)) # For non-user responses + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + ticket = relationship("SupportTicket", back_populates="responses") + author = relationship("User") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py index 5eeb033..2f9c93b 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -32,6 +32,7 @@ class User(BaseModel): # Relationships audit_logs = relationship("AuditLog", back_populates="user") + submitted_tickets = relationship("SupportTicket", foreign_keys="SupportTicket.user_id", back_populates="submitter") def __repr__(self): return f"" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7cbe303..d12dc41 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,33 +2,33 @@ # Python 3.12+ FastAPI Backend Requirements # Core Web Framework -fastapi==0.104.1 -uvicorn[standard]==0.24.0 -gunicorn==21.2.0 +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +gunicorn==23.0.0 # Database -sqlalchemy==2.0.23 -alembic==1.13.1 +sqlalchemy==2.0.36 +alembic==1.14.0 # Authentication & Security -python-multipart==0.0.6 +python-multipart==0.0.12 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 -bcrypt==4.1.2 +bcrypt==4.0.1 # Data Validation -pydantic==2.5.2 -pydantic-settings==2.1.0 -email-validator==2.1.0 +pydantic==2.10.3 +pydantic-settings==2.7.0 +email-validator==2.2.0 # Templates & Static Files -jinja2==3.1.2 -aiofiles==23.2.1 +jinja2==3.1.4 +aiofiles==24.1.0 # Testing -pytest==7.4.3 -pytest-asyncio==0.21.1 -httpx==0.25.2 +pytest==8.3.4 +pytest-asyncio==0.24.0 +httpx==0.28.1 # Development -python-dotenv==1.0.0 \ No newline at end of file +python-dotenv==1.0.1 \ No newline at end of file diff --git a/templates/admin.html b/templates/admin.html index 56bd861..4f30d88 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -94,6 +94,18 @@ Maintenance + + -