feat(coc): professional CoC with accreditation badges + signature + company branding
Problem: the rebuilt CoC rendered mostly empty because accreditation logos had to be uploaded manually via Settings first, and no signature existed — looked unprofessional next to the Steelhead reference. Fix: - Seeder now auto-generates clean text-based accreditation badges with PIL (Nadcap blue, AS9100D/ISO 9001 blue, CGP red) sized to match the reference layout. Client can swap in real trademarked logos via Settings → Fusion Plating → Accreditation Logos at any time. - Seeder creates a demo "Kris Pathinather" user, sets them as the certificate owner on res.company, and renders a scripted-looking signature image that matches the printed name on the cert. - Seeder uploads a generated "Amphenol Canada Corp." badge to Amphenol's res.partner.image_1920 so that customer's CoCs include their logo on the top-right corner (mirrors how the reference shows it). - coc_body template: guard hr.employee.signature access with a field- exists check (the field is provided by an optional module not installed on every Odoo). - CoC uses web.html_container directly instead of wrapping in web.basic_layout — the outer wrapper was injecting top padding that pushed the title ~25% down the page. Now starts cleanly at the top. - Tightened CoC CSS: removed unused label classes, added @page margin directive, fixed vertical-align on header cells so logos and company contact stay middle-aligned regardless of row height. - Invoice PDF PAID stamp now also triggers on payment_state = 'in_payment', so historical demo invoices look paid without needing full bank reconciliation. Verified: renders a 152KB PDF with 5 embedded images, signer name matches signature, all accreditation badges visible. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,155 @@ def set_date_field(rec, field, days_ago):
|
||||
rec.write({field: d})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Phase 0.5: Company CoC settings (accreditation badges + signature)
|
||||
# ============================================================
|
||||
# We generate clean PIL-based badge PNGs for Nadcap / AS9100 / CGP
|
||||
# so the CoC PDF renders complete without the client having to upload
|
||||
# anything. They can still replace them with the real trademarked logos
|
||||
# via Settings → Fusion Plating → Accreditation Logos whenever they want.
|
||||
|
||||
def _make_badge(lines, width=420, height=220, bg='#0066A1', fg='white',
|
||||
border_color='#003d66', border_px=6, font_size=42,
|
||||
subtitle=None, subtitle_color='white', subtitle_size=18):
|
||||
"""Render a rectangular badge with centred stacked text."""
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
LOG(" PIL not available — skipping badge generation")
|
||||
return None
|
||||
import io
|
||||
img = Image.new('RGB', (width, height), bg)
|
||||
draw = ImageDraw.Draw(img)
|
||||
# Border
|
||||
draw.rectangle([(border_px // 2, border_px // 2),
|
||||
(width - border_px, height - border_px)],
|
||||
outline=border_color, width=border_px)
|
||||
# Pick a sans-serif bold font
|
||||
font = None
|
||||
for candidate in (
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
|
||||
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
|
||||
'/System/Library/Fonts/Helvetica.ttc',
|
||||
'DejaVuSans-Bold.ttf',
|
||||
):
|
||||
try:
|
||||
font = ImageFont.truetype(candidate, font_size)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if font is None:
|
||||
font = ImageFont.load_default()
|
||||
sub_font = None
|
||||
if subtitle:
|
||||
for candidate in (
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
||||
'/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
|
||||
):
|
||||
try:
|
||||
sub_font = ImageFont.truetype(candidate, subtitle_size)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if sub_font is None:
|
||||
sub_font = font
|
||||
# Stack lines vertically, centred
|
||||
line_gap = int(font_size * 1.2)
|
||||
total_h = line_gap * len(lines) + (subtitle_size + 10 if subtitle else 0)
|
||||
y = (height - total_h) // 2
|
||||
for line in lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
tw = bbox[2] - bbox[0]
|
||||
draw.text(((width - tw) / 2, y), line, fill=fg, font=font)
|
||||
y += line_gap
|
||||
if subtitle:
|
||||
y += 4
|
||||
bbox = draw.textbbox((0, 0), subtitle, font=sub_font)
|
||||
tw = bbox[2] - bbox[0]
|
||||
draw.text(((width - tw) / 2, y), subtitle, fill=subtitle_color, font=sub_font)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG', optimize=True)
|
||||
return base64.b64encode(buf.getvalue())
|
||||
|
||||
|
||||
def _make_signature(name, width=700, height=180, color='#00338D'):
|
||||
"""Render a plausible handwritten-looking signature from a name."""
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
return None
|
||||
import io
|
||||
img = Image.new('RGBA', (width, height), (255, 255, 255, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
# Prefer an italic / oblique font for the script look
|
||||
font = None
|
||||
for candidate in (
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf',
|
||||
'/usr/share/fonts/truetype/liberation/LiberationSans-Italic.ttf',
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSansCondensed-BoldOblique.ttf',
|
||||
):
|
||||
try:
|
||||
font = ImageFont.truetype(candidate, 88)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if font is None:
|
||||
font = ImageFont.load_default()
|
||||
draw.text((30, 30), name, fill=color, font=font)
|
||||
# Underline flourish
|
||||
bbox = draw.textbbox((30, 30), name, font=font)
|
||||
draw.line(
|
||||
[(30, bbox[3] + 10), (bbox[2] + 80, bbox[3] + 10)],
|
||||
fill=color, width=3,
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG', optimize=True)
|
||||
return base64.b64encode(buf.getvalue())
|
||||
|
||||
|
||||
LOG("Phase 0.5: Company CoC settings (badges + signature)")
|
||||
_company = env.company
|
||||
|
||||
# Build accreditation badges (only if not already set to avoid clobbering
|
||||
# real logos the client uploaded via Settings)
|
||||
if not _company.x_fc_nadcap_logo:
|
||||
_company.x_fc_nadcap_logo = _make_badge(
|
||||
['NADCAP', 'ACCREDITED'],
|
||||
bg='#0066A1', border_color='#003d66',
|
||||
subtitle='Administered by PRI',
|
||||
)
|
||||
if not _company.x_fc_as9100_logo:
|
||||
_company.x_fc_as9100_logo = _make_badge(
|
||||
['AS9100D', 'CERTIFIED'],
|
||||
bg='#2B6CB0', border_color='#1a4d80',
|
||||
subtitle='ISO 9001',
|
||||
)
|
||||
if not _company.x_fc_cgp_logo:
|
||||
_company.x_fc_cgp_logo = _make_badge(
|
||||
['CGP', 'REGISTERED'],
|
||||
bg='#C8102E', border_color='#8B0A1F',
|
||||
subtitle="Canada's Controlled Goods Program",
|
||||
subtitle_size=15,
|
||||
)
|
||||
_company.x_fc_nadcap_active = True
|
||||
_company.x_fc_as9100_active = True
|
||||
_company.x_fc_cgp_active = True
|
||||
|
||||
# Designate a demo owner: a user named "Kris Pathinather" so the
|
||||
# Certified By / Name line on the CoC matches the signature image.
|
||||
_kris_user = env['res.users'].search([('login', '=', 'kris.pathinather')], limit=1)
|
||||
if not _kris_user:
|
||||
_kris_user = env['res.users'].with_context(no_reset_password=True).create({
|
||||
'name': 'Kris Pathinather',
|
||||
'login': 'kris.pathinather',
|
||||
'email': 'kris@enplating.ca',
|
||||
})
|
||||
_company.x_fc_owner_user_id = _kris_user.id
|
||||
# Always refresh signature (cheap, looks clean)
|
||||
_company.x_fc_coc_signature_override = _make_signature('Kris Pathinather')
|
||||
LOG(f" Accreditation badges + signature generated — owner: {_kris_user.name}")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Phase 1: Customers (6 stories)
|
||||
# ============================================================
|
||||
@@ -78,6 +227,14 @@ amphenol = ensure_partner('FPD-AMPHENOL', {
|
||||
'state_id': env.ref('base.state_ca_on').id,
|
||||
'website': 'amphenolcanada.com',
|
||||
})
|
||||
# Give Amphenol their trademark blue-block logo
|
||||
if not amphenol.image_1920:
|
||||
amphenol.image_1920 = _make_badge(
|
||||
['Amphenol'], width=320, height=200,
|
||||
bg='#005EB8', border_color='#003c75',
|
||||
font_size=46,
|
||||
subtitle='Canada Corp.', subtitle_size=20,
|
||||
)
|
||||
|
||||
magellan = ensure_partner('FPD-MAGELLAN', {
|
||||
'name': 'Magellan Aerospace Ltd',
|
||||
|
||||
Reference in New Issue
Block a user