Compare commits

...

4 Commits

Author SHA1 Message Date
HotSwapp
2e49340663 feat(case): add GET /case/{id} detail view and Jinja template; link from dashboard table; eager-load related data; 404 handling and logging 2025-10-06 19:21:58 -05:00
HotSwapp
6174df42b4 feat(dashboard): list recent cases with search and pagination\n\n- Add q, page, page_size to /dashboard route\n- Join clients and filter by file_no/name/company\n- Bootstrap table UI with search form and pagination\n- Log query params; preserve auth/session\n\nCo-authored-by: AI Assistant <ai@example.com> 2025-10-06 19:11:40 -05:00
HotSwapp
6aa4d59a25 feat(auth): add session-based login/logout with bcrypt hashing, seed default admin, templates and navbar updates; add auth middleware; pin SQLAlchemy 1.4.x for Py3.13; update TODOs 2025-10-06 19:04:36 -05:00
HotSwapp
227c74294f feat: Set up SessionMiddleware and Jinja2 Template Configuration
- Add SECRET_KEY environment variable and .env file for session management
- Configure SessionMiddleware with FastAPI for user session handling
- Set up Jinja2 template engine with template directory configuration
- Mount static files directory for CSS, JS, and image assets
- Create comprehensive base.html template with Bootstrap 5 CDN
- Add Bootstrap Icons and custom styling support
- Include responsive navigation with user authentication state
- Create placeholder CSS and JavaScript files for customization
- Add aiofiles dependency for static file serving

This establishes the web framework foundation with session management
and templating system ready for frontend development.
2025-10-06 18:27:44 -05:00
19 changed files with 1143 additions and 19 deletions

3
.env Normal file
View File

@@ -0,0 +1,3 @@
# Delphi Database Environment Configuration
SECRET_KEY=your-secret-key-here-change-this-in-production
DATABASE_URL=sqlite:///./delphi.db

16
TODO.md
View File

@@ -6,20 +6,20 @@ Refer to `del.plan.md` for context. Check off items as theyre completed.
- [ ] Create requirements.txt with minimal deps - [ ] Create requirements.txt with minimal deps
- [ ] Copy delphi-logo.webp into static/logo/ - [ ] Copy delphi-logo.webp into static/logo/
- [ ] Set up SQLAlchemy Base and engine/session helpers - [ ] Set up SQLAlchemy Base and engine/session helpers
- [ ] Add User model with username and password_hash - [x] Add User model with username and password_hash
- [ ] Add Client model (rolodex_id and core fields) - [ ] Add Client model (rolodex_id and core fields)
- [ ] Add Phone model with FK to Client - [ ] Add Phone model with FK to Client
- [ ] Add Case model (file_no unique, FK to Client) - [ ] Add Case model (file_no unique, FK to Client)
- [ ] Add Transaction model with FK to Case - [ ] Add Transaction model with FK to Case
- [ ] Add Document model with FK to Case - [ ] Add Document model with FK to Case
- [ ] Add Payment model with FK to Case - [ ] Add Payment model with FK to Case
- [ ] Create tables and seed default admin user - [x] Create tables and seed default admin user
- [ ] Create FastAPI app with DB session dependency - [x] Create FastAPI app with DB session dependency
- [ ] Add SessionMiddleware with SECRET_KEY from env - [x] Add SessionMiddleware with SECRET_KEY from env
- [ ] Configure Jinja2 templates and mount static files - [x] Configure Jinja2 templates and mount static files
- [ ] Create base.html with Bootstrap 5 CDN and nav - [x] Create base.html with Bootstrap 5 CDN and nav
- [ ] Implement login form, POST handler, and logout - [x] Implement login form, POST handler, and logout
- [ ] Create login.html form - [x] Create login.html form
- [ ] Implement dashboard route listing cases - [ ] Implement dashboard route listing cases
- [ ] Add simple search by file_no/name/keyword - [ ] Add simple search by file_no/name/keyword
- [ ] Create dashboard.html with table and search box - [ ] Create dashboard.html with table and search box

Binary file not shown.

Binary file not shown.

186
app/auth.py Normal file
View File

@@ -0,0 +1,186 @@
"""
Authentication utilities for Delphi Database application.
This module provides password hashing, user authentication, and session management
functions for secure user login/logout functionality.
"""
import logging
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from .models import User
from .database import SessionLocal
# Configure password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
logger = logging.getLogger(__name__)
def hash_password(password: str) -> str:
"""
Hash a password using bcrypt.
Args:
password (str): Plain text password to hash
Returns:
str: Hashed password
"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a password against its hash.
Args:
plain_password (str): Plain text password to verify
hashed_password (str): Hashed password to check against
Returns:
bool: True if password matches hash, False otherwise
"""
return pwd_context.verify(plain_password, hashed_password)
def authenticate_user(username: str, password: str) -> User | None:
"""
Authenticate a user with username and password.
Args:
username (str): Username to authenticate
password (str): Password to verify
Returns:
User | None: User object if authentication successful, None otherwise
"""
db = SessionLocal()
try:
user = db.query(User).filter(User.username == username).first()
if not user:
logger.warning(f"Authentication failed: User '{username}' not found")
return None
if not verify_password(password, user.password_hash):
logger.warning(f"Authentication failed: Invalid password for user '{username}'")
return None
if not user.is_active:
logger.warning(f"Authentication failed: User '{username}' is inactive")
return None
logger.info(f"User '{username}' authenticated successfully")
return user
except Exception as e:
logger.error(f"Authentication error for user '{username}': {e}")
return None
finally:
db.close()
def create_user(username: str, password: str, is_active: bool = True) -> User | None:
"""
Create a new user with hashed password.
Args:
username (str): Username for the new user
password (str): Plain text password (will be hashed)
is_active (bool): Whether the user should be active
Returns:
User | None: Created user object if successful, None if user already exists
"""
db = SessionLocal()
try:
# Check if user already exists
existing_user = db.query(User).filter(User.username == username).first()
if existing_user:
logger.warning(f"User creation failed: User '{username}' already exists")
return None
# Hash the password
password_hash = hash_password(password)
# Create new user
new_user = User(
username=username,
password_hash=password_hash,
is_active=is_active
)
db.add(new_user)
db.commit()
db.refresh(new_user)
logger.info(f"User '{username}' created successfully")
return new_user
except Exception as e:
db.rollback()
logger.error(f"Error creating user '{username}': {e}")
return None
finally:
db.close()
def seed_admin_user() -> None:
"""
Create a default admin user if one doesn't exist.
This function should be called during application startup to ensure
there's at least one admin user for initial access.
"""
admin_username = "admin"
admin_password = "admin123" # In production, use a more secure default
db = SessionLocal()
try:
# Check if admin user already exists
existing_admin = db.query(User).filter(User.username == admin_username).first()
if existing_admin:
logger.info(f"Admin user '{admin_username}' already exists")
return
# Create admin user
admin_user = create_user(admin_username, admin_password)
if admin_user:
logger.info(f"Default admin user '{admin_username}' created successfully")
else:
logger.error("Failed to create default admin user")
except Exception as e:
logger.error(f"Error seeding admin user: {e}")
finally:
db.close()
def get_current_user_from_session(session_data: dict) -> User | None:
"""
Get current user from session data.
Args:
session_data (dict): Session data dictionary
Returns:
User | None: Current user if session is valid, None otherwise
"""
user_id = session_data.get("user_id")
if not user_id:
return None
db = SessionLocal()
try:
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
if not user:
logger.warning(f"No active user found for session user_id: {user_id}")
return None
return user
except Exception as e:
logger.error(f"Error retrieving current user from session: {e}")
return None
finally:
db.close()

View File

@@ -33,8 +33,8 @@ engine = create_engine(
# Create session factory # Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create declarative base for models # Import Base from models for SQLAlchemy 1.x compatibility
Base = declarative_base() from .models import Base
def get_db() -> Generator[Session, None, None]: def get_db() -> Generator[Session, None, None]:
@@ -68,6 +68,14 @@ def create_tables() -> None:
""" """
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# Seed default admin user after creating tables
try:
from .auth import seed_admin_user
seed_admin_user()
except ImportError:
# Handle case where auth module isn't available yet during initial import
pass
def get_database_url() -> str: def get_database_url() -> str:
""" """

View File

@@ -5,20 +5,68 @@ This module initializes the FastAPI application, sets up database connections,
and provides the main application instance. and provides the main application instance.
""" """
import os
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends from fastapi import FastAPI, Depends, Request, Query
from sqlalchemy.orm import Session from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_
from dotenv import load_dotenv
from starlette.middleware.base import BaseHTTPMiddleware
from .database import create_tables, get_db, get_database_url from .database import create_tables, get_db, get_database_url
from .models import User from .models import User, Case, Client
from .auth import authenticate_user, get_current_user_from_session
# Load environment variables
load_dotenv()
# Get SECRET_KEY from environment variables
SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY:
raise ValueError("SECRET_KEY environment variable must be set")
# Configure logging # Configure logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Configure Jinja2 templates
templates = Jinja2Templates(directory="app/templates")
class AuthMiddleware(BaseHTTPMiddleware):
"""
Simple session-based authentication middleware.
Redirects unauthenticated users to /login for protected routes.
"""
def __init__(self, app, exempt_paths: list[str] | None = None):
super().__init__(app)
self.exempt_paths = exempt_paths or []
async def dispatch(self, request, call_next):
path = request.url.path
# Allow exempt paths and static assets
if (
path in self.exempt_paths
or path.startswith("/static")
or path.startswith("/favicon")
):
return await call_next(request)
# Enforce authentication for other paths
if not request.session.get("user_id"):
return RedirectResponse(url="/login", status_code=302)
return await call_next(request)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -54,6 +102,25 @@ app = FastAPI(
lifespan=lifespan lifespan=lifespan
) )
# Add CORS middleware for cross-origin requests
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, specify allowed origins
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register authentication middleware with exempt paths
EXEMPT_PATHS = ["/", "/health", "/login", "/logout"]
app.add_middleware(AuthMiddleware, exempt_paths=EXEMPT_PATHS)
# Add SessionMiddleware for session management (must be added LAST so it runs FIRST)
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
# Mount static files directory
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/") @app.get("/")
async def root(): async def root():
@@ -83,3 +150,222 @@ async def health_check(db: Session = Depends(get_db)):
"database": "error", "database": "error",
"error": str(e) "error": str(e)
} }
@app.get("/login")
async def login_form(request: Request):
"""
Display login form.
If user is already logged in, redirect to dashboard.
"""
# Check if user is already logged in
user = get_current_user_from_session(request.session)
if user:
return RedirectResponse(url="/dashboard", status_code=302)
return templates.TemplateResponse("login.html", {"request": request})
@app.post("/login")
async def login_submit(request: Request, db: Session = Depends(get_db)):
"""
Handle login form submission.
Authenticates user credentials and sets up session.
"""
form = await request.form()
username = form.get("username")
password = form.get("password")
if not username or not password:
error_message = "Username and password are required"
return templates.TemplateResponse("login.html", {
"request": request,
"error": error_message
})
# Authenticate user
user = authenticate_user(username, password)
if not user:
error_message = "Invalid username or password"
return templates.TemplateResponse("login.html", {
"request": request,
"error": error_message
})
# Set up user session
request.session["user_id"] = user.id
request.session["user"] = {"id": user.id, "username": user.username}
logger.info(f"User '{username}' logged in successfully")
# Redirect to dashboard after successful login
return RedirectResponse(url="/dashboard", status_code=302)
@app.get("/logout")
async def logout(request: Request):
"""
Handle user logout.
Clears user session and redirects to home page.
"""
username = request.session.get("user", {}).get("username", "unknown")
request.session.clear()
logger.info(f"User '{username}' logged out")
return RedirectResponse(url="/", status_code=302)
@app.get("/dashboard")
async def dashboard(
request: Request,
q: str | None = Query(None, description="Search by file number or client name"),
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
page_size: int = Query(20, ge=1, le=100, description="Results per page"),
db: Session = Depends(get_db),
):
"""
Dashboard page - lists recent cases with search and pagination.
- Optional query param `q` filters by case file number or client name/company
- `page` and `page_size` control pagination
"""
# Check authentication
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
# Base query: join clients for name/company access
query = db.query(Case).join(Client).order_by(
Case.open_date.desc(),
Case.created_at.desc(),
)
# Apply search filter if provided
if q:
like_term = f"%{q}%"
query = query.filter(
or_(
Case.file_no.ilike(like_term),
Client.first_name.ilike(like_term),
Client.last_name.ilike(like_term),
Client.company.ilike(like_term),
)
)
# Total count for pagination
total: int = query.count()
# Clamp page to valid range when total is known
total_pages: int = (total + page_size - 1) // page_size if total > 0 else 1
if page > total_pages:
page = total_pages
# Pagination window
offset = (page - 1) * page_size
cases = query.offset(offset).limit(page_size).all()
# Page number window for UI (current +/- 2)
start_page = max(1, page - 2)
end_page = min(total_pages, page + 2)
page_numbers = list(range(start_page, end_page + 1))
logger.info(
"Rendering dashboard: q='%s', page=%s, page_size=%s, total=%s",
q,
page,
page_size,
total,
)
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"user": user,
"cases": cases,
"q": q,
"page": page,
"page_size": page_size,
"total": total,
"total_pages": total_pages,
"page_numbers": page_numbers,
"start_index": (offset + 1) if total > 0 else 0,
"end_index": min(offset + len(cases), total),
},
)
@app.get("/admin")
async def admin_panel(request: Request, db: Session = Depends(get_db)):
"""
Admin panel - requires authentication.
Provides administrative functions like data import and system management.
"""
# Check authentication
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
return templates.TemplateResponse("admin.html", {
"request": request,
"user": user
})
@app.get("/case/{case_id}")
async def case_detail(
request: Request,
case_id: int,
db: Session = Depends(get_db),
):
"""
Case detail view.
Displays detailed information for a single case and its related client and
associated records (transactions, documents, payments).
"""
# Check authentication
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
# Fetch case with related entities eagerly loaded to avoid lazy-load issues
case_obj = (
db.query(Case)
.options(
joinedload(Case.client),
joinedload(Case.transactions),
joinedload(Case.documents),
joinedload(Case.payments),
)
.filter(Case.id == case_id)
.first()
)
if not case_obj:
logger.warning("Case not found: id=%s", case_id)
return templates.TemplateResponse(
"case.html",
{
"request": request,
"user": user,
"case": None,
"error": "Case not found",
},
status_code=404,
)
logger.info("Rendering case detail: id=%s, file_no='%s'", case_obj.id, case_obj.file_no)
return templates.TemplateResponse(
"case.html",
{
"request": request,
"user": user,
"case": case_obj,
},
)

View File

@@ -6,8 +6,11 @@ All models inherit from Base which is configured in the database module.
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float, Text, Boolean from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float, Text, Boolean
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func from sqlalchemy.sql import func
from .database import Base
# Create Base for SQLAlchemy 1.x compatibility
Base = declarative_base()
class User(Base): class User(Base):

View File

@@ -1 +1,87 @@
<!-- Base template with Bootstrap 5 and navigation --> <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Delphi Database{% endblock %}</title>
<!-- Bootstrap 5 CSS CDN -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<!-- Custom CSS -->
<link href="{{ url_for('static', path='/css/custom.css') }}" rel="stylesheet">
{% block extra_head %}{% endblock %}
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<img src="{{ url_for('static', path='/logo/delphi-logo.webp') }}" alt="Delphi Logo" width="30" height="30" class="d-inline-block align-text-top me-2">
Delphi Database
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {% if request.url.path == '/' %}active{% endif %}" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link {% if 'dashboard' in request.url.path %}active{% endif %}" href="/dashboard">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link {% if 'admin' in request.url.path %}active{% endif %}" href="/admin">Admin</a>
</li>
</ul>
<ul class="navbar-nav">
{% if request.session.get('user') %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-person-circle me-1"></i>{{ request.session.get('user').username if request.session.get('user') else '' }}
</a>
<ul class="dropdown-menu" aria-labelledby="userDropdown">
<li><a class="dropdown-item" href="/profile">Profile</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/logout">Logout</a></li>
</ul>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link {% if 'login' in request.url.path %}active{% endif %}" href="/login">Login</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="container-fluid mt-4">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="bg-light text-center text-muted mt-5 py-3">
<div class="container">
<small>&copy; 2025 Delphi Database. All rights reserved.</small>
</div>
</footer>
<!-- Bootstrap 5 JS Bundle CDN -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', path='/js/custom.js') }}"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -1 +1,183 @@
<!-- Case view/edit form --> {% extends "base.html" %}
{% block title %}
Case {{ case.file_no if case else '' }} · Delphi Database
{% endblock %}
{% block content %}
<div class="row g-3">
<div class="col-12 d-flex align-items-center">
<a class="btn btn-sm btn-outline-secondary me-2" href="/dashboard">
<i class="bi bi-arrow-left"></i>
Back
</a>
<h2 class="mb-0">Case Details</h2>
</div>
{% if error %}
<div class="col-12">
<div class="alert alert-danger" role="alert">{{ error }}</div>
</div>
{% endif %}
{% if case %}
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="row mb-3">
<div class="col-md-3">
<div class="text-muted small">File #</div>
<div class="fw-semibold">{{ case.file_no }}</div>
</div>
<div class="col-md-3">
<div class="text-muted small">Status</div>
<div>
{% if case.status == 'active' %}
<span class="badge bg-success">Active</span>
{% elif case.status == 'closed' %}
<span class="badge bg-secondary">Closed</span>
{% else %}
<span class="badge bg-light text-dark">{{ case.status or 'n/a' }}</span>
{% endif %}
</div>
</div>
<div class="col-md-3">
<div class="text-muted small">Type</div>
<div>{{ case.case_type or '' }}</div>
</div>
<div class="col-md-3">
<div class="text-muted small">Opened</div>
<div>{{ case.open_date.strftime('%Y-%m-%d') if case.open_date else '' }}</div>
</div>
</div>
<div class="row mb-3">
{% set client = case.client %}
<div class="col-md-4">
<div class="text-muted small">Client</div>
<div>
{% if client %}
{{ client.last_name }}, {{ client.first_name }}
{% else %}
<span class="text-muted">Unknown</span>
{% endif %}
</div>
</div>
<div class="col-md-4">
<div class="text-muted small">Company</div>
<div>{{ client.company if client else '' }}</div>
</div>
<div class="col-md-4">
<div class="text-muted small">City/State</div>
<div>
{% if client %}
{{ client.city or '' }}{% if client.state %}, {{ client.state }}{% endif %}
{% endif %}
</div>
</div>
</div>
<div class="mb-2 text-muted small">Description</div>
<p class="mb-0">{{ case.description or '' }}</p>
</div>
</div>
</div>
<div class="col-xl-4">
<div class="card h-100">
<div class="card-header">Transactions</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width: 110px;">Date</th>
<th>Type</th>
<th class="text-end" style="width: 120px;">Amount</th>
</tr>
</thead>
<tbody>
{% if case.transactions and case.transactions|length > 0 %}
{% for t in case.transactions %}
<tr>
<td>{{ t.transaction_date.strftime('%Y-%m-%d') if t.transaction_date else '' }}</td>
<td>{{ t.transaction_type or '' }}</td>
<td class="text-end">{{ '%.2f'|format(t.amount) if t.amount is not none else '' }}</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="3" class="text-center text-muted py-3">No transactions.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-xl-4">
<div class="card h-100">
<div class="card-header">Documents</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Type</th>
<th>File</th>
<th style="width: 120px;">Uploaded</th>
</tr>
</thead>
<tbody>
{% if case.documents and case.documents|length > 0 %}
{% for d in case.documents %}
<tr>
<td>{{ d.document_type or '' }}</td>
<td>{{ d.file_name or '' }}</td>
<td>{{ d.uploaded_date.strftime('%Y-%m-%d') if d.uploaded_date else '' }}</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="3" class="text-center text-muted py-3">No documents.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-xl-4">
<div class="card h-100">
<div class="card-header">Payments</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width: 110px;">Date</th>
<th>Type</th>
<th class="text-end" style="width: 120px;">Amount</th>
</tr>
</thead>
<tbody>
{% if case.payments and case.payments|length > 0 %}
{% for p in case.payments %}
<tr>
<td>{{ p.payment_date.strftime('%Y-%m-%d') if p.payment_date else '' }}</td>
<td>{{ p.payment_type or '' }}</td>
<td class="text-end">{{ '%.2f'|format(p.amount) if p.amount is not none else '' }}</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="3" class="text-center text-muted py-3">No payments.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -1 +1,122 @@
<!-- Dashboard with case listing and search --> {% extends "base.html" %}
{% block title %}Dashboard · Delphi Database{% endblock %}
{% block content %}
<div class="row g-3 align-items-center mb-3">
<div class="col-auto">
<h2 class="mb-0">Cases</h2>
</div>
<div class="col ms-auto">
<form class="d-flex" method="get" action="/dashboard">
<input
class="form-control me-2"
type="search"
name="q"
placeholder="Search file # or name/company"
aria-label="Search"
value="{{ q or '' }}"
>
<input type="hidden" name="page_size" value="{{ page_size }}">
<button class="btn btn-outline-primary" type="submit">
<i class="bi bi-search me-1"></i>Search
</button>
</form>
</div>
<div class="col-auto">
<a class="btn btn-outline-secondary" href="/dashboard">
<i class="bi bi-x-circle me-1"></i>Clear
</a>
</div>
<div class="col-12 text-muted small">
{% if total and total > 0 %}
Showing {{ start_index }}{{ end_index }} of {{ total }}
{% else %}
No results
{% endif %}
</div>
<div class="col-12">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th scope="col" style="width: 140px;">File #</th>
<th scope="col">Client</th>
<th scope="col">Company</th>
<th scope="col">Type</th>
<th scope="col">Status</th>
<th scope="col">Opened</th>
<th scope="col" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% if cases and cases|length > 0 %}
{% for c in cases %}
<tr>
<td><span class="fw-semibold">{{ c.file_no }}</span></td>
<td>
{% set client = c.client %}
{% if client %}
{{ client.last_name }}, {{ client.first_name }}
{% else %}
<span class="text-muted">Unknown</span>
{% endif %}
</td>
<td>{{ client.company if client else '' }}</td>
<td>{{ c.case_type or '' }}</td>
<td>
{% if c.status == 'active' %}
<span class="badge bg-success">Active</span>
{% elif c.status == 'closed' %}
<span class="badge bg-secondary">Closed</span>
{% else %}
<span class="badge bg-light text-dark">{{ c.status or 'n/a' }}</span>
{% endif %}
</td>
<td>{{ c.open_date.strftime('%Y-%m-%d') if c.open_date else '' }}</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/case/{{ c.id }}">
<i class="bi bi-folder2-open me-1"></i>View
</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="7" class="text-center text-muted py-4">No cases found.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<div class="col-12">
{% if total_pages and total_pages > 1 %}
<nav aria-label="Cases pagination">
<ul class="pagination mb-0">
{# Previous #}
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
<a class="page-link" href="/dashboard?page={{ page - 1 if page > 1 else 1 }}&page_size={{ page_size }}{% if q %}&q={{ q | urlencode }}{% endif %}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{# Page numbers window #}
{% for p in page_numbers %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="/dashboard?page={{ p }}&page_size={{ page_size }}{% if q %}&q={{ q | urlencode }}{% endif %}">{{ p }}</a>
</li>
{% endfor %}
{# Next #}
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
<a class="page-link" href="/dashboard?page={{ page + 1 if page < total_pages else total_pages }}&page_size={{ page_size }}{% if q %}&q={{ q | urlencode }}{% endif %}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -1 +1,111 @@
<!-- Login form template --> {% extends "base.html" %}
{% block title %}Login - Delphi Database{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-center mb-4">
<img src="{{ url_for('static', path='/logo/delphi-logo.webp') }}" alt="Delphi Logo" class="mb-3" style="width: 60px; height: 60px;">
<h2 class="card-title">Welcome Back</h2>
<p class="text-muted">Sign in to access Delphi Database</p>
</div>
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
</div>
{% endif %}
<form method="post" action="/login">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input type="text" class="form-control" id="username" name="username" required
placeholder="Enter your username" autocomplete="username">
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="password" class="form-control" id="password" name="password" required
placeholder="Enter your password" autocomplete="current-password">
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="rememberMe">
<label class="form-check-label" for="rememberMe">
Remember me
</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="bi bi-box-arrow-in-right me-2"></i>Sign In
</button>
</div>
</form>
<div class="text-center mt-4">
<small class="text-muted">
<i class="bi bi-info-circle me-1"></i>
Default credentials: admin / admin123
</small>
</div>
</div>
</div>
<div class="text-center mt-3">
<small class="text-muted">
Don't have an account? Contact your administrator.
</small>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Auto-focus on username field
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('username').focus();
});
// Show/hide password toggle (optional enhancement)
document.addEventListener('DOMContentLoaded', function() {
const passwordField = document.getElementById('password');
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = 'btn btn-outline-secondary';
toggleBtn.innerHTML = '<i class="bi bi-eye"></i>';
toggleBtn.style.border = 'none';
toggleBtn.style.background = 'transparent';
// Add toggle functionality
toggleBtn.addEventListener('click', function() {
if (passwordField.type === 'password') {
passwordField.type = 'text';
this.innerHTML = '<i class="bi bi-eye-slash"></i>';
} else {
passwordField.type = 'password';
this.innerHTML = '<i class="bi bi-eye"></i>';
}
});
// Insert toggle button into password input group
const passwordInputGroup = passwordField.closest('.input-group');
if (passwordInputGroup) {
const span = passwordInputGroup.querySelector('.input-group-text');
passwordInputGroup.insertBefore(toggleBtn, span.nextSibling);
}
});
</script>
{% endblock %}

5
cookies.txt Normal file
View File

@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1761003936 session eyJ1c2VyX2lkIjogMSwgInVzZXIiOiB7ImlkIjogMSwgInVzZXJuYW1lIjogImFkbWluIn19.aORUoA.KSAst9pcXJJJHTc1m-mY_jQ0P6A

BIN
delphi.db

Binary file not shown.

View File

@@ -1,5 +1,5 @@
fastapi==0.104.1 fastapi==0.104.1
sqlalchemy==2.0.23 sqlalchemy==1.4.54
alembic==1.12.1 alembic==1.12.1
python-multipart==0.0.6 python-multipart==0.0.6
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
@@ -7,3 +7,4 @@ passlib[bcrypt]==1.7.4
python-dotenv==1.0.0 python-dotenv==1.0.0
uvicorn[standard]==0.24.0 uvicorn[standard]==0.24.0
jinja2==3.1.2 jinja2==3.1.2
aiofiles==23.2.1

51
static/css/custom.css Normal file
View File

@@ -0,0 +1,51 @@
/* Custom CSS for Delphi Database */
/* Additional styles can be added here */
body {
background-color: #f8f9fa;
}
/* Custom navbar styles */
.navbar-brand {
font-weight: bold;
}
/* Custom card styles */
.card {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
border: 1px solid rgba(0, 0, 0, 0.125);
}
/* Custom button styles */
.btn-primary {
background-color: #0d6efd;
border-color: #0d6efd;
}
.btn-primary:hover {
background-color: #0b5ed7;
border-color: #0a58ca;
}
/* Custom form styles */
.form-control:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
/* Custom table styles */
.table th {
background-color: #f8f9fa;
border-bottom: 2px solid #dee2e6;
}
/* Custom alert styles */
.alert {
border: none;
border-radius: 0.375rem;
}
/* Custom footer styles */
footer {
margin-top: auto;
}

82
static/js/custom.js Normal file
View File

@@ -0,0 +1,82 @@
// Custom JavaScript for Delphi Database
document.addEventListener('DOMContentLoaded', function() {
// Initialize tooltips if any
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// Auto-hide alerts after 5 seconds
var alerts = document.querySelectorAll('.alert:not(.alert-permanent)');
alerts.forEach(function(alert) {
setTimeout(function() {
var bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}, 5000);
});
// Confirm delete actions
var deleteButtons = document.querySelectorAll('[data-confirm-delete]');
deleteButtons.forEach(function(button) {
button.addEventListener('click', function(e) {
var message = this.getAttribute('data-confirm-delete') || 'Are you sure you want to delete this item?';
if (!confirm(message)) {
e.preventDefault();
}
});
});
// Form validation enhancement
var forms = document.querySelectorAll('.needs-validation');
Array.prototype.slice.call(forms).forEach(function(form) {
form.addEventListener('submit', function(event) {
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated');
}, false);
});
// Dynamic form field enabling/disabling
var toggleFields = document.querySelectorAll('[data-toggle-field]');
toggleFields.forEach(function(element) {
element.addEventListener('change', function() {
var targetSelector = this.getAttribute('data-toggle-field');
var targetField = document.querySelector(targetSelector);
if (targetField) {
targetField.disabled = !this.checked;
}
});
});
});
// Utility functions
function formatDate(dateString) {
if (!dateString) return '';
var date = new Date(dateString);
return date.toLocaleDateString();
}
function formatCurrency(amount) {
if (!amount) return '$0.00';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
}
function showLoading(button) {
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Loading...';
}
}
function hideLoading(button, originalText) {
if (button) {
button.disabled = false;
button.innerHTML = originalText;
}
}