supports export
This commit is contained in:
277
app.py
277
app.py
@@ -2,7 +2,7 @@ import os
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import Flask, render_template, request, redirect, url_for, session, abort, jsonify
|
||||
from flask import Flask, render_template, request, redirect, url_for, session, abort, jsonify, send_file
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
from filevine_client import FilevineClient
|
||||
@@ -10,6 +10,9 @@ from utils import get_user_profile
|
||||
from firebase_init import db
|
||||
from firebase_admin import auth as fb_auth
|
||||
import config
|
||||
import openpyxl
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
@@ -191,6 +194,278 @@ def dashboard(page=1):
|
||||
total_projects=total_projects,
|
||||
per_page=per_page)
|
||||
|
||||
|
||||
@app.route("/dashboard/export_xls")
|
||||
@login_required
|
||||
def dashboard_export_xls():
|
||||
"""Export all dashboard data to XLS format without pagination."""
|
||||
uid = session.get("uid")
|
||||
profile = get_user_profile(uid)
|
||||
if not profile.get("enabled"):
|
||||
return redirect(url_for("welcome"))
|
||||
|
||||
is_admin = profile.get("is_admin")
|
||||
case_email = None
|
||||
if not is_admin:
|
||||
case_email = profile.get("case_email")
|
||||
if not case_email:
|
||||
return redirect(url_for("welcome"))
|
||||
if is_admin and request.args.get('case_email'):
|
||||
case_email = request.args.get('case_email').lower()
|
||||
# Validate email format
|
||||
if '@' not in case_email:
|
||||
return abort(400, "Invalid email format")
|
||||
|
||||
# Get all projects without pagination
|
||||
try:
|
||||
projects_ref = db.collection("projects")
|
||||
# Filter projects where case_email is in viewing_emails array
|
||||
if case_email:
|
||||
projects_ref = projects_ref.where("viewing_emails", "array_contains", case_email.lower())
|
||||
|
||||
# Order by matter_description to maintain consistent ordering
|
||||
projects_ref = projects_ref.order_by("matter_description")
|
||||
|
||||
docs = projects_ref.stream()
|
||||
all_rows = []
|
||||
|
||||
for doc in docs:
|
||||
all_rows.append(doc.to_dict())
|
||||
|
||||
print(f"Retrieved {len(all_rows)} projects from Firestore for XLS export")
|
||||
|
||||
# Create workbook and worksheet
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Projects"
|
||||
|
||||
# Define the column headers (matching the dashboard columns)
|
||||
headers = [
|
||||
'Matter Num',
|
||||
'Matter Description',
|
||||
'Client / Property',
|
||||
'Defendant 1',
|
||||
'Matter Open',
|
||||
'Practice Area',
|
||||
'Notice Type',
|
||||
'Case Number',
|
||||
'Premises Address',
|
||||
'Premises City',
|
||||
'Assigned Attorney',
|
||||
'Primary Contact',
|
||||
'Secondary Paralegal',
|
||||
'Documents',
|
||||
'Matter Stage',
|
||||
'Completed Tasks',
|
||||
'Pending Tasks',
|
||||
'Notice Service Date',
|
||||
'Notice Expir. Date',
|
||||
'Date Case Filed',
|
||||
'Daily Rent Damages',
|
||||
'Default Date',
|
||||
'Default Entered On',
|
||||
'Motions:',
|
||||
'Demurrer Hearing Date',
|
||||
'Motion To Strike Hearing Date',
|
||||
'Motion to Quash Hearing Date',
|
||||
'Other Motion Hearing Date',
|
||||
'MSC Date',
|
||||
'MSC Time',
|
||||
'MSC Address',
|
||||
'MSC Div/ Dept/ Room',
|
||||
'Trial Date',
|
||||
'Trial Time',
|
||||
'Trial Address',
|
||||
'Trial Div/ Dept/ Room',
|
||||
'Final Result of Trial/ MSC',
|
||||
'Date of Settlement',
|
||||
'Final Obligation Under the Stip',
|
||||
'Def\'s Comply with the Stip?',
|
||||
'Judgment Date',
|
||||
'Writ Issued Date',
|
||||
'Scheduled Lockout',
|
||||
'Oppose Stays?',
|
||||
'Premises Safety or Access Issues',
|
||||
'Matter Gate or Entry Code',
|
||||
'Date Possession Recovered',
|
||||
'Attorney\'s Fees',
|
||||
'Costs'
|
||||
]
|
||||
|
||||
# Write headers
|
||||
for col_num, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col_num, value=header)
|
||||
cell.font = openpyxl.styles.Font(bold=True)
|
||||
|
||||
# Write data rows
|
||||
for row_num, row_data in enumerate(all_rows, 2):
|
||||
for col_num, header in enumerate(headers, 1):
|
||||
# Map header names to data fields
|
||||
field_name = None
|
||||
if header == 'Matter Num':
|
||||
field_name = 'number'
|
||||
elif header == 'Matter Description':
|
||||
field_name = 'matter_description'
|
||||
elif header == 'Client / Property':
|
||||
field_name = 'client'
|
||||
elif header == 'Defendant 1':
|
||||
field_name = 'defendant_1'
|
||||
elif header == 'Matter Open':
|
||||
field_name = 'matter_open'
|
||||
elif header == 'Practice Area':
|
||||
field_name = 'practice_area' # This field doesn't exist in model, so we'll leave blank
|
||||
elif header == 'Notice Type':
|
||||
field_name = 'notice_type'
|
||||
elif header == 'Case Number':
|
||||
field_name = 'case_number'
|
||||
elif header == 'Premises Address':
|
||||
field_name = 'premises_address'
|
||||
elif header == 'Premises City':
|
||||
field_name = 'premises_city'
|
||||
elif header == 'Assigned Attorney':
|
||||
field_name = 'responsible_attorney'
|
||||
elif header == 'Primary Contact':
|
||||
field_name = 'staff_person'
|
||||
elif header == 'Secondary Paralegal':
|
||||
field_name = 'staff_person_2'
|
||||
elif header == 'Documents':
|
||||
field_name = 'documents_url' # Just the URL, not the link
|
||||
elif header == 'Matter Stage':
|
||||
field_name = 'phase_name'
|
||||
elif header == 'Completed Tasks':
|
||||
# Convert list of dicts to string representation
|
||||
if 'completed_tasks' in row_data and row_data['completed_tasks']:
|
||||
task_strings = []
|
||||
for task in row_data['completed_tasks'][:5]: # Limit to 5 tasks for readability
|
||||
desc = task.get('description', '')
|
||||
completed = task.get('completed', '')
|
||||
task_strings.append(f"{desc} ({completed})")
|
||||
field_value = '; '.join(task_strings)
|
||||
else:
|
||||
field_value = ''
|
||||
ws.cell(row=row_num, column=col_num, value=field_value)
|
||||
continue # Skip to next field
|
||||
elif header == 'Pending Tasks':
|
||||
# Convert list of dicts to string representation
|
||||
if 'pending_tasks' in row_data and row_data['pending_tasks']:
|
||||
task_strings = []
|
||||
for task in row_data['pending_tasks'][:5]: # Limit to 5 tasks for readability
|
||||
desc = task.get('description', '')
|
||||
task_strings.append(desc)
|
||||
field_value = '; '.join(task_strings)
|
||||
else:
|
||||
field_value = ''
|
||||
ws.cell(row=row_num, column=col_num, value=field_value)
|
||||
continue # Skip to next field
|
||||
elif header == 'Notice Service Date':
|
||||
field_name = 'notice_service_date'
|
||||
elif header == 'Notice Expir. Date':
|
||||
field_name = 'notice_expiration_date'
|
||||
elif header == 'Date Case Filed':
|
||||
field_name = 'case_field_date'
|
||||
elif header == 'Daily Rent Damages':
|
||||
field_name = 'daily_rent_damages'
|
||||
elif header == 'Default Date':
|
||||
field_name = 'default_date'
|
||||
elif header == 'Default Entered On':
|
||||
field_name = 'default_entered_on_date'
|
||||
elif header == 'Motions:':
|
||||
field_name = 'motions' # This field doesn't exist in model, so we'll leave blank
|
||||
elif header == 'Demurrer Hearing Date':
|
||||
field_name = 'demurrer_hearing_date'
|
||||
elif header == 'Motion To Strike Hearing Date':
|
||||
field_name = 'motion_to_strike_hearing_date'
|
||||
elif header == 'Motion to Quash Hearing Date':
|
||||
field_name = 'motion_to_quash_hearing_date'
|
||||
elif header == 'Other Motion Hearing Date':
|
||||
field_name = 'other_motion_hearing_date'
|
||||
elif header == 'MSC Date':
|
||||
field_name = 'msc_date'
|
||||
elif header == 'MSC Time':
|
||||
field_name = 'msc_time'
|
||||
elif header == 'MSC Address':
|
||||
field_name = 'msc_address'
|
||||
elif header == 'MSC Div/ Dept/ Room':
|
||||
field_name = 'msc_div_dept_room'
|
||||
elif header == 'Trial Date':
|
||||
field_name = 'trial_date'
|
||||
elif header == 'Trial Time':
|
||||
field_name = 'trial_time'
|
||||
elif header == 'Trial Address':
|
||||
field_name = 'trial_address'
|
||||
elif header == 'Trial Div/ Dept/ Room':
|
||||
field_name = 'trial_div_dept_room'
|
||||
elif header == 'Final Result of Trial/ MSC':
|
||||
field_name = 'final_result'
|
||||
elif header == 'Date of Settlement':
|
||||
field_name = 'date_of_settlement'
|
||||
elif header == 'Final Obligation Under the Stip':
|
||||
field_name = 'final_obligation'
|
||||
elif header == 'Def\'s Comply with the Stip?':
|
||||
field_name = 'def_comply_stip'
|
||||
elif header == 'Judgment Date':
|
||||
field_name = 'judgment_date'
|
||||
elif header == 'Writ Issued Date':
|
||||
field_name = 'writ_issued_date'
|
||||
elif header == 'Scheduled Lockout':
|
||||
field_name = 'scheduled_lockout'
|
||||
elif header == 'Oppose Stays?':
|
||||
field_name = 'oppose_stays'
|
||||
elif header == 'Premises Safety or Access Issues':
|
||||
field_name = 'premises_safety'
|
||||
elif header == 'Matter Gate or Entry Code':
|
||||
field_name = 'matter_gate_code'
|
||||
elif header == 'Date Possession Recovered':
|
||||
field_name = 'date_possession_recovered'
|
||||
elif header == 'Attorney\'s Fees':
|
||||
field_name = 'attorney_fees'
|
||||
elif header == 'Costs':
|
||||
field_name = 'costs'
|
||||
|
||||
# Set the cell value
|
||||
if field_name and field_name in row_data:
|
||||
field_value = row_data[field_name]
|
||||
# Handle special cases for formatting
|
||||
if field_name == 'daily_rent_damages' and field_value:
|
||||
# Format as currency if it's a number
|
||||
try:
|
||||
field_value = f"${float(field_value):,.2f}"
|
||||
except:
|
||||
pass # Keep as-is if not a number
|
||||
ws.cell(row=row_num, column=col_num, value=field_value)
|
||||
else:
|
||||
# For fields that don't exist in the model or are special cases, set to empty
|
||||
ws.cell(row=row_num, column=col_num, value='')
|
||||
|
||||
# Auto-adjust column widths
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
column_letter = get_column_letter(column[0].column)
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
adjusted_width = min(max_length + 2, 50) # Cap at 50 characters
|
||||
ws.column_dimensions[column_letter].width = adjusted_width
|
||||
|
||||
# Save to temporary file and return
|
||||
import tempfile
|
||||
import os
|
||||
temp_dir = tempfile.gettempdir()
|
||||
filename = f"dashboard_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
filepath = os.path.join(temp_dir, filename)
|
||||
|
||||
wb.save(filepath)
|
||||
|
||||
return send_file(filepath, as_attachment=True, download_name=filename)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to export XLS: {e}")
|
||||
return abort(500, "Failed to export data")
|
||||
|
||||
|
||||
import admin
|
||||
|
||||
# Register admin routes
|
||||
|
||||
@@ -5,3 +5,4 @@ requests==2.32.3
|
||||
itsdangerous==2.2.0
|
||||
gunicorn==23.0.0
|
||||
pytz
|
||||
openpyxl==3.1.2
|
||||
|
||||
@@ -2,80 +2,96 @@
|
||||
{% block content %}
|
||||
<div class="h-full flex flex-col" x-data="columnConfig()">
|
||||
<h1 class="text-xl font-semibold mb-4">Projects for {{ case_email }}</h1>
|
||||
<div class="flex justify-between">
|
||||
|
||||
{% set profile = get_user_profile(session.uid) %}
|
||||
{% if profile.is_admin %}
|
||||
<div class="mb-4 flex w-[400px]">
|
||||
<label for="simulateCaseEmail" class=" text-sm font-medium text-slate-700 mb-1">Simulate case email:</label>
|
||||
<input type="text" id="simulateCaseEmail" x-model="case_email_sim"
|
||||
@keyup.debounce.1000ms="window.location.href=`/dashboard/1?case_email=${encodeURIComponent($data.case_email_sim)}`"
|
||||
class="w-full px-3 py-2 border w-64 border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Enter case email to simulate">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Per Page Dropdown -->
|
||||
<div class="mb-4 flex items-center">
|
||||
<label for="perPage" class="mr-2 text-sm font-medium text-slate-700">Items per page:</label>
|
||||
<select id="perPage" x-model="perPage" @change="window.location.href = `/dashboard/1?per_page=${$data.perPage}${$data.case_email_sim ? '&case_email=' + encodeURIComponent($data.case_email_sim) : ''}`"
|
||||
class="px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Configure Visible Columns Link -->
|
||||
<div class="mb-4">
|
||||
<button @click="showColumnModal = true" class="text-blue-600 hover:text-blue-800 text-sm font-medium underline">
|
||||
Configure Visible Columns...
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Column Configuration Modal -->
|
||||
<div x-show="showColumnModal" x-cloak x-transition
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
@click.self="showColumnModal = false">
|
||||
<div class="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">Configure Visible Columns</h3>
|
||||
<button @click="showColumnModal = false" class="text-gray-500 hover:text-gray-700">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{% set profile = get_user_profile(session.uid) %}
|
||||
{% if profile.is_admin %}
|
||||
<div class="mb-4 flex w-[400px]">
|
||||
<label for="simulateCaseEmail" class=" text-sm font-medium text-slate-700 mb-1">Simulate case email:</label>
|
||||
<input type="text" id="simulateCaseEmail" x-model="case_email_sim"
|
||||
@keyup.debounce.1000ms="window.location.href=`/dashboard/1?case_email=${encodeURIComponent($data.case_email_sim)}`"
|
||||
class="w-full px-3 py-2 border w-64 border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Enter case email to simulate">
|
||||
</div>
|
||||
{% else %}
|
||||
<div></div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<!-- Export Button -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="selectAll" @change="toggleAllColumns()"
|
||||
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="text-sm font-medium">Select All / Deselect All</span>
|
||||
</label>
|
||||
<a href="{{ url_for('dashboard_export_xls') }}" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
<svg class="mr-2 -ml-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
Export to Excel
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-96 overflow-y-auto">
|
||||
<template x-for="(column, index) in columns" :key="index">
|
||||
<label class="flex items-center p-2 hover:bg-slate-50 rounded cursor-pointer">
|
||||
<input type="checkbox" :value="column" x-model="visibleColumns" @change="saveColumnSettings()"
|
||||
:checked="visibleColumns.includes(column)"
|
||||
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="text-sm" x-text="column"></span>
|
||||
</label>
|
||||
</template>
|
||||
<!-- Per Page Dropdown -->
|
||||
<div class="mb-4 flex items-center">
|
||||
<label for="perPage" class="mr-2 text-sm font-medium text-slate-700">Items per page:</label>
|
||||
<select id="perPage" x-model="perPage" @change="window.location.href = `/dashboard/1?per_page=${$data.perPage}${$data.case_email_sim ? '&case_email=' + encodeURIComponent($data.case_email_sim) : ''}`"
|
||||
class="px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button @click="resetToDefault()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors">
|
||||
Reset to Default
|
||||
</button>
|
||||
<button @click="showColumnModal = false;"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors">
|
||||
Apply Changes
|
||||
<!-- Configure Visible Columns Link -->
|
||||
<div class="mb-4">
|
||||
<button @click="showColumnModal = true" class="text-blue-600 hover:text-blue-800 text-sm font-medium underline">
|
||||
Configure Visible Columns...
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Column Configuration Modal -->
|
||||
<div x-show="showColumnModal" x-cloak x-transition
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
@click.self="showColumnModal = false">
|
||||
<div class="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">Configure Visible Columns</h3>
|
||||
<button @click="showColumnModal = false" class="text-gray-500 hover:text-gray-700">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="selectAll" @change="toggleAllColumns()"
|
||||
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="text-sm font-medium">Select All / Deselect All</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-96 overflow-y-auto">
|
||||
<template x-for="(column, index) in columns" :key="index">
|
||||
<label class="flex items-center p-2 hover:bg-slate-50 rounded cursor-pointer">
|
||||
<input type="checkbox" :value="column" x-model="visibleColumns" @change="saveColumnSettings()"
|
||||
:checked="visibleColumns.includes(column)"
|
||||
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="text-sm" x-text="column"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button @click="resetToDefault()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors">
|
||||
Reset to Default
|
||||
</button>
|
||||
<button @click="showColumnModal = false;"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors">
|
||||
Apply Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% from "_expander.html" import expander %}
|
||||
|
||||
Reference in New Issue
Block a user