- controllers/portal.py: FamilyLawPortal with 8 routes (cases list, case detail, calculator pre-fill, caselaw library, deadline complete AJAX, public intake landing/form/submit) - views/portal_case_templates.xml: portal home card, case list, full case detail with timeline widget, AI summary, DV safety banner - views/portal_calculator_templates.xml: FL 61.30 interactive calculator - views/portal_caselaw_templates.xml: searchable case law library (EN/ES) - views/website_intake_templates.xml: public 4-step intake form with DV quick-exit, fee waiver, and intake confirmation page - static/src/css/familylaw_portal.css: full portal/website CSS (EN/ES lang toggle, deadline card color coding, timeline, AI summary box) - static/src/js/fl_calculator.js: FL 61.30 schedule lookup, above- schedule formula, FL 61.30(11)(b) substantial timesharing calculation - static/src/js/fl_timeline.js: deadline timeline widget with filter buttons and mark-complete AJAX - __init__.py: import controllers package - __manifest__.py: add Phase 6 portal view files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
318 lines
16 KiB
Python
318 lines
16 KiB
Python
import json
|
|
import logging
|
|
from datetime import date, timedelta
|
|
|
|
from odoo import http
|
|
from odoo.http import request
|
|
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FamilyLawPortal(CustomerPortal):
|
|
"""
|
|
Phase 6 — Portal controller for fl.case, fl.caselaw, calculator, and intake.
|
|
|
|
Routes:
|
|
GET /my/cases — list of petitioner/respondent cases
|
|
GET /my/cases/<int:case_id> — case detail with deadlines/timeline
|
|
GET /my/cases/calculator — FL 61.30 interactive calculator
|
|
GET /my/cases/caselaw — FL case law library (searchable)
|
|
POST /family-law/deadline/complete — AJAX: mark deadline complete
|
|
GET /family-law/intake — public landing page
|
|
GET /family-law/intake/start — multi-step intake form
|
|
POST /family-law/intake/submit — process intake form → create case
|
|
"""
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Portal home — add case_count for the "My Cases" card
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
def _prepare_home_portal_values(self, counters):
|
|
values = super()._prepare_home_portal_values(counters)
|
|
if 'case_count' in counters:
|
|
partner = request.env.user.partner_id
|
|
cases = request.env['fl.case'].search([
|
|
'|',
|
|
('petitioner_id.user_ids', 'in', request.env.user.ids),
|
|
('respondent_id.user_ids', 'in', request.env.user.ids),
|
|
])
|
|
values['case_count'] = len(cases)
|
|
return values
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# /my/cases — case list
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
@http.route('/my/cases', type='http', auth='user', website=True)
|
|
def portal_my_cases(self, **kwargs):
|
|
cases = request.env['fl.case'].search([
|
|
'|',
|
|
('petitioner_id.user_ids', 'in', request.env.user.ids),
|
|
('respondent_id.user_ids', 'in', request.env.user.ids),
|
|
], order='create_date desc')
|
|
|
|
return request.render('activeblue_familylaw.portal_my_cases', {
|
|
'cases': cases,
|
|
'page_name': 'cases',
|
|
})
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# /my/cases/<case_id> — case detail
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
@http.route('/my/cases/<int:case_id>', type='http', auth='user', website=True)
|
|
def portal_case_detail(self, case_id, **kwargs):
|
|
# Access check: user must be petitioner or respondent
|
|
case = request.env['fl.case'].search([
|
|
('id', '=', case_id),
|
|
'|',
|
|
('petitioner_id.user_ids', 'in', request.env.user.ids),
|
|
('respondent_id.user_ids', 'in', request.env.user.ids),
|
|
], limit=1)
|
|
|
|
if not case:
|
|
return request.render('website.403')
|
|
|
|
latest_analysis = case.analysis_ids[:1] if case.analysis_ids else None
|
|
|
|
return request.render('activeblue_familylaw.portal_case_detail', {
|
|
'case': case,
|
|
'latest_analysis': latest_analysis,
|
|
'page_name': 'case_detail',
|
|
})
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# /my/cases/calculator — FL 61.30 calculator
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
@http.route('/my/cases/calculator', type='http', auth='public', website=True)
|
|
def portal_calculator(self, case_id=None, **kwargs):
|
|
"""
|
|
Pre-fill calculator from case data if case_id is provided and user is authenticated.
|
|
"""
|
|
prefill_petitioner = None
|
|
prefill_respondent = None
|
|
|
|
if case_id and request.env.user and request.env.user.id != request.env.ref('base.public_user').id:
|
|
case = request.env['fl.case'].search([
|
|
('id', '=', int(case_id)),
|
|
'|',
|
|
('petitioner_id.user_ids', 'in', request.env.user.ids),
|
|
('respondent_id.user_ids', 'in', request.env.user.ids),
|
|
], limit=1)
|
|
if case:
|
|
prefill_petitioner = case.petitioner_net_income
|
|
prefill_respondent = case.respondent_net_income
|
|
|
|
return request.render('activeblue_familylaw.portal_calculator', {
|
|
'prefill_petitioner': prefill_petitioner,
|
|
'prefill_respondent': prefill_respondent,
|
|
'page_name': 'calculator',
|
|
})
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# /my/cases/caselaw — case law library
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
@http.route('/my/cases/caselaw', type='http', auth='public', website=True)
|
|
def portal_caselaw(self, tag=None, court=None, **kwargs):
|
|
domain = [('active', '=', True)]
|
|
if tag:
|
|
domain += [('issue_tag_ids.name', '=', tag)]
|
|
if court:
|
|
domain += [('court', '=', court)]
|
|
|
|
cases = request.env['fl.caselaw'].search(domain, order='year desc, short_name')
|
|
|
|
return request.render('activeblue_familylaw.portal_caselaw', {
|
|
'cases': cases,
|
|
'page_name': 'caselaw',
|
|
})
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# AJAX: /family-law/deadline/complete
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
@http.route('/family-law/deadline/complete', type='json', auth='user', methods=['POST'], csrf=True)
|
|
def deadline_complete(self, deadline_id=None, **kwargs):
|
|
if not deadline_id:
|
|
return {'success': False, 'error': 'Missing deadline_id'}
|
|
|
|
deadline = request.env['fl.deadline'].search([
|
|
('id', '=', deadline_id),
|
|
'|',
|
|
('case_id.petitioner_id.user_ids', 'in', request.env.user.ids),
|
|
('case_id.respondent_id.user_ids', 'in', request.env.user.ids),
|
|
], limit=1)
|
|
|
|
if not deadline:
|
|
return {'success': False, 'error': 'Deadline not found or access denied'}
|
|
|
|
try:
|
|
deadline.action_complete()
|
|
return {'success': True}
|
|
except Exception as e:
|
|
_logger.error("Portal deadline complete error: %s", e)
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Public: /family-law/intake — landing page
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
@http.route('/family-law/intake', type='http', auth='public', website=True)
|
|
def intake_landing(self, **kwargs):
|
|
return request.render('activeblue_familylaw.website_intake_landing', {
|
|
'page_name': 'intake',
|
|
})
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Public: /family-law/intake/start — multi-step form
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
@http.route('/family-law/intake/start', type='http', auth='public', website=True)
|
|
def intake_start(self, case_type=None, **kwargs):
|
|
return request.render('activeblue_familylaw.website_intake_form', {
|
|
'case_type': case_type or 'child_support',
|
|
'page_name': 'intake_form',
|
|
})
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Public: POST /family-law/intake/submit — process intake → create case
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
@http.route('/family-law/intake/submit', type='http', auth='public', website=True, methods=['POST'], csrf=True)
|
|
def intake_submit(self, **post):
|
|
"""
|
|
Process intake form submission.
|
|
Creates or finds partner records, creates fl.case, triggers fee waiver check
|
|
and optional AI analysis, then redirects to confirmation page.
|
|
"""
|
|
try:
|
|
petitioner_name = (post.get('petitioner_name') or '').strip()
|
|
petitioner_email = (post.get('petitioner_email') or '').strip()
|
|
respondent_name = (post.get('respondent_name') or '').strip()
|
|
case_type = post.get('case_type') or 'child_support'
|
|
num_children = int(post.get('num_children') or 1)
|
|
court_case_number = (post.get('court_case_number') or '').strip() or False
|
|
domestic_violence = post.get('domestic_violence') == 'yes'
|
|
respondent_has_counsel = post.get('respondent_has_counsel') == 'yes'
|
|
income_concern = post.get('income_imputation_concern') == 'yes'
|
|
petitioner_income = float(post.get('petitioner_income') or 0)
|
|
respondent_income = float(post.get('respondent_income') or 0)
|
|
current_order = float(post.get('current_order_amount') or 0)
|
|
household_size = int(post.get('household_size') or 3)
|
|
fee_waiver_request = post.get('fee_waiver_request') == 'yes'
|
|
notes = (post.get('notes') or '').strip()
|
|
fl_resident_since = post.get('fl_resident_since') or False
|
|
|
|
# Use sudo for partner/case creation (public user has limited rights)
|
|
env = request.env['fl.case'].sudo()
|
|
|
|
# Find or create petitioner partner
|
|
petitioner = request.env['res.partner'].sudo().search(
|
|
[('email', '=', petitioner_email)], limit=1
|
|
)
|
|
if not petitioner:
|
|
petitioner = request.env['res.partner'].sudo().create({
|
|
'name': petitioner_name,
|
|
'email': petitioner_email,
|
|
'phone': post.get('petitioner_phone') or False,
|
|
'street': post.get('petitioner_address') or False,
|
|
'city': post.get('petitioner_city') or False,
|
|
'state_id': request.env.ref('base.state_us_10').id, # FL
|
|
'country_id': request.env.ref('base.us').id,
|
|
})
|
|
|
|
# Find or create respondent partner
|
|
respondent = False
|
|
if respondent_name:
|
|
respondent = request.env['res.partner'].sudo().search(
|
|
[('name', '=', respondent_name)], limit=1
|
|
)
|
|
if not respondent:
|
|
respondent = request.env['res.partner'].sudo().create({
|
|
'name': respondent_name,
|
|
})
|
|
|
|
# Build fl.party records (minimal)
|
|
petitioner_party = request.env['fl.party'].sudo().search(
|
|
[('partner_id', '=', petitioner.id)], limit=1
|
|
)
|
|
if not petitioner_party:
|
|
petitioner_party = request.env['fl.party'].sudo().create({
|
|
'name': petitioner_name,
|
|
'email': petitioner_email,
|
|
'employment_status': 'employed',
|
|
'monthly_gross_income': petitioner_income,
|
|
})
|
|
|
|
respondent_party = False
|
|
if respondent:
|
|
respondent_party = request.env['fl.party'].sudo().search(
|
|
[('partner_id', '=', respondent.id)], limit=1
|
|
)
|
|
if not respondent_party:
|
|
respondent_party = request.env['fl.party'].sudo().create({
|
|
'name': respondent_name,
|
|
'monthly_gross_income': respondent_income,
|
|
})
|
|
|
|
# Parse FL resident since date
|
|
filing_date = date.today()
|
|
residency_ok = True
|
|
if fl_resident_since:
|
|
try:
|
|
res_date = date.fromisoformat(fl_resident_since)
|
|
residency_ok = (filing_date - res_date).days >= 180
|
|
except ValueError:
|
|
pass
|
|
|
|
# Create the case
|
|
case_vals = {
|
|
'case_type': case_type,
|
|
'petitioner_id': petitioner_party.id,
|
|
'respondent_id': respondent_party.id if respondent_party else False,
|
|
'court_case_number': court_case_number,
|
|
'domestic_violence_flag': domestic_violence,
|
|
'respondent_has_counsel': respondent_has_counsel,
|
|
'current_order_amount': current_order,
|
|
'filing_date': filing_date,
|
|
'residency_requirement_met': residency_ok,
|
|
'household_size': household_size,
|
|
}
|
|
if notes:
|
|
case_vals['description'] = notes
|
|
|
|
case = request.env['fl.case'].sudo().create(case_vals)
|
|
|
|
# Fee waiver check
|
|
attorney_referral = domestic_violence or respondent_has_counsel
|
|
fee_waiver_eligible = False
|
|
if fee_waiver_request:
|
|
fpl = request.env['fl.fee.waiver'].sudo().search([('case_id', '=', case.id)], limit=1)
|
|
if fpl:
|
|
fee_waiver_eligible = fpl.is_eligible
|
|
|
|
# Trigger AI analysis (async-ish: do it now, portal will show result)
|
|
try:
|
|
request.env['fl.ai.engine'].sudo().analyze_case(case.id)
|
|
except Exception as e:
|
|
_logger.warning("Portal intake: AI analysis failed for case %s: %s", case.id, e)
|
|
|
|
return request.render('activeblue_familylaw.website_intake_confirm', {
|
|
'case_name': case.name,
|
|
'petitioner_email': petitioner_email,
|
|
'fee_waiver_eligible': fee_waiver_eligible,
|
|
'attorney_referral': attorney_referral,
|
|
'page_name': 'intake_confirm',
|
|
})
|
|
|
|
except Exception as exc:
|
|
_logger.error("Intake submission error: %s", exc, exc_info=True)
|
|
return request.render('activeblue_familylaw.website_intake_form', {
|
|
'case_type': post.get('case_type', 'child_support'),
|
|
'error': str(exc),
|
|
'page_name': 'intake_form',
|
|
})
|