changes
This commit is contained in:
@@ -32,6 +32,7 @@ Provides:
|
||||
'depends': [
|
||||
'fusion_plating',
|
||||
'sale_management',
|
||||
'fusion_pdf_preview',
|
||||
],
|
||||
'data': [
|
||||
'security/fp_configurator_security.xml',
|
||||
@@ -52,6 +53,10 @@ Provides:
|
||||
'fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss',
|
||||
'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
|
||||
'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
|
||||
'fusion_plating_configurator/static/src/xml/fp_drawing_preview.xml',
|
||||
'fusion_plating_configurator/static/src/js/fp_drawing_preview.js',
|
||||
'fusion_plating_configurator/static/src/xml/fp_pdf_inline_preview.xml',
|
||||
'fusion_plating_configurator/static/src/js/fp_pdf_inline_preview.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
|
||||
@@ -46,7 +46,10 @@ class FpPartCatalog(models.Model):
|
||||
[('3d_model', '3D Model'), ('manual', 'Manual Measurements'), ('pdf_drawing', 'PDF Drawing')],
|
||||
string='Geometry Source', default='manual',
|
||||
)
|
||||
model_attachment_id = fields.Many2one('ir.attachment', string='3D Model File', help='STEP, STL, or IGES file.')
|
||||
model_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='3D Model File',
|
||||
help='STEP, STL, or IGES file.', tracking=True,
|
||||
)
|
||||
drawing_attachment_ids = fields.Many2many(
|
||||
'ir.attachment', 'fp_part_catalog_drawing_rel', 'part_catalog_id', 'attachment_id', string='PDF Drawings',
|
||||
)
|
||||
@@ -68,9 +71,104 @@ class FpPartCatalog(models.Model):
|
||||
has_blind_holes = fields.Boolean(string='Has Blind Holes')
|
||||
has_recesses = fields.Boolean(string='Has Recesses')
|
||||
has_threads = fields.Boolean(string='Has Threads')
|
||||
|
||||
# ---- Auto-extracted from 3D model (OCC-computed) ----
|
||||
volume_mm3 = fields.Float(
|
||||
string='Volume (mm³)', digits=(16, 2),
|
||||
help='Auto-calculated from 3D model.',
|
||||
)
|
||||
bbox_length_mm = fields.Float(string='BBox Length (mm)', digits=(12, 2))
|
||||
bbox_width_mm = fields.Float(string='BBox Width (mm)', digits=(12, 2))
|
||||
bbox_height_mm = fields.Float(string='BBox Height (mm)', digits=(12, 2))
|
||||
bbox_summary_in = fields.Char(
|
||||
string='Dimensions (in)',
|
||||
compute='_compute_bbox_summary_in',
|
||||
store=True,
|
||||
help='Bounding box L × W × H in inches.',
|
||||
)
|
||||
material_weight_kg = fields.Float(
|
||||
string='Material Weight (kg)', digits=(12, 4),
|
||||
compute='_compute_material_weight',
|
||||
store=True,
|
||||
help='volume × substrate density.',
|
||||
)
|
||||
is_manifold = fields.Boolean(
|
||||
string='Watertight (Manifold)',
|
||||
help='False indicates open/broken geometry — review before quoting.',
|
||||
)
|
||||
hole_count = fields.Integer(
|
||||
string='Cylindrical Features',
|
||||
help='Total cylindrical surfaces detected (includes holes, bores, '
|
||||
'and rounded fillets — see breakdown for diameter clustering).',
|
||||
)
|
||||
hole_summary = fields.Char(
|
||||
string='Feature Diameters',
|
||||
help='Cylindrical features grouped by diameter (rounded to 0.5mm). '
|
||||
'Small diameters are usually edge fillets; larger ones are typically holes.',
|
||||
)
|
||||
masking_area_sqin = fields.Float(
|
||||
string='Masking Area (sq in)', digits=(12, 4),
|
||||
help='Total area excluded from plating (masked surfaces).',
|
||||
)
|
||||
effective_area_sqin = fields.Float(
|
||||
string='Effective Plating Area (sq in)', digits=(12, 4),
|
||||
compute='_compute_effective_area',
|
||||
store=True,
|
||||
help='Surface area minus masked area — used for per-sq-in pricing.',
|
||||
)
|
||||
|
||||
notes = fields.Html(string='Notes')
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
|
||||
# Substrate density mapping (g/cm³) for material weight calculation
|
||||
_SUBSTRATE_DENSITY = {
|
||||
'aluminium': 2.70,
|
||||
'steel': 7.85,
|
||||
'stainless': 8.00,
|
||||
'copper': 8.96,
|
||||
'titanium': 4.51,
|
||||
'other': 7.85, # default to steel
|
||||
}
|
||||
|
||||
@api.depends('volume_mm3', 'substrate_material')
|
||||
def _compute_material_weight(self):
|
||||
for rec in self:
|
||||
if not rec.volume_mm3 or not rec.substrate_material:
|
||||
rec.material_weight_kg = 0.0
|
||||
continue
|
||||
density = self._SUBSTRATE_DENSITY.get(rec.substrate_material, 7.85)
|
||||
# mm³ × g/cm³ × 1e-6 = kg
|
||||
rec.material_weight_kg = round(rec.volume_mm3 * density * 1e-6, 4)
|
||||
|
||||
@api.depends('bbox_length_mm', 'bbox_width_mm', 'bbox_height_mm')
|
||||
def _compute_bbox_summary_in(self):
|
||||
for rec in self:
|
||||
if not (rec.bbox_length_mm or rec.bbox_width_mm or rec.bbox_height_mm):
|
||||
rec.bbox_summary_in = False
|
||||
continue
|
||||
# mm to inches
|
||||
l = rec.bbox_length_mm / 25.4
|
||||
w = rec.bbox_width_mm / 25.4
|
||||
h = rec.bbox_height_mm / 25.4
|
||||
rec.bbox_summary_in = '%.2f × %.2f × %.2f in' % (l, w, h)
|
||||
|
||||
@api.depends('surface_area', 'surface_area_uom', 'masking_area_sqin')
|
||||
def _compute_effective_area(self):
|
||||
for rec in self:
|
||||
# Convert surface_area to sq in
|
||||
uom = rec.surface_area_uom or 'sq_in'
|
||||
if uom == 'sq_in':
|
||||
area_sqin = rec.surface_area
|
||||
elif uom == 'sq_ft':
|
||||
area_sqin = rec.surface_area * 144.0
|
||||
elif uom == 'sq_cm':
|
||||
area_sqin = rec.surface_area / 6.4516
|
||||
elif uom == 'sq_m':
|
||||
area_sqin = rec.surface_area * 1550.0
|
||||
else:
|
||||
area_sqin = rec.surface_area
|
||||
rec.effective_area_sqin = max(0.0, area_sqin - (rec.masking_area_sqin or 0.0))
|
||||
|
||||
sale_order_count = fields.Integer(
|
||||
string='Sale Orders', compute='_compute_sale_order_count',
|
||||
)
|
||||
@@ -83,6 +181,77 @@ class FpPartCatalog(models.Model):
|
||||
'Part number must be unique per customer.'),
|
||||
]
|
||||
|
||||
def write(self, vals):
|
||||
"""Track changes to attachments and propagate to linked configurators."""
|
||||
# Snapshot before write
|
||||
snapshots = {}
|
||||
track_3d = 'model_attachment_id' in vals
|
||||
track_drawings = 'drawing_attachment_ids' in vals
|
||||
if track_3d or track_drawings:
|
||||
for rec in self:
|
||||
snapshots[rec.id] = {
|
||||
'model': rec.model_attachment_id,
|
||||
'drawings': set(rec.drawing_attachment_ids.ids),
|
||||
}
|
||||
|
||||
result = super().write(vals)
|
||||
|
||||
# Log changes after write
|
||||
for rec in self:
|
||||
snap = snapshots.get(rec.id)
|
||||
if not snap:
|
||||
continue
|
||||
|
||||
messages = []
|
||||
|
||||
# 3D model change
|
||||
if track_3d:
|
||||
old = snap['model']
|
||||
new = rec.model_attachment_id
|
||||
if not old and new:
|
||||
messages.append(_('<b>3D model attached:</b> %s') % new.name)
|
||||
elif old and not new:
|
||||
messages.append(_('<b>3D model removed:</b> %s') % old.name)
|
||||
elif old and new and old.id != new.id:
|
||||
messages.append(_('<b>3D model changed:</b> %s → %s') % (old.name, new.name))
|
||||
|
||||
# Drawing changes (added or removed)
|
||||
if track_drawings:
|
||||
old_ids = snap['drawings']
|
||||
new_ids = set(rec.drawing_attachment_ids.ids)
|
||||
added = new_ids - old_ids
|
||||
removed = old_ids - new_ids
|
||||
for att_id in added:
|
||||
att = self.env['ir.attachment'].browse(att_id)
|
||||
if att.exists():
|
||||
messages.append(_('<b>Drawing attached:</b> %s') % att.name)
|
||||
for att_id in removed:
|
||||
att = self.env['ir.attachment'].browse(att_id)
|
||||
# Browse even if deleted — may still have name if not purged
|
||||
name = att.exists() and att.name or f'#{att_id}'
|
||||
messages.append(_('<b>Drawing removed:</b> %s') % name)
|
||||
|
||||
if messages:
|
||||
body = '<br/>'.join(messages)
|
||||
# Post to part catalog chatter
|
||||
rec.message_post(
|
||||
body=body,
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
# Propagate to linked configurator quotes (draft + confirmed)
|
||||
configurators = self.env['fp.quote.configurator'].search([
|
||||
('part_catalog_id', '=', rec.id),
|
||||
])
|
||||
for cfg in configurators:
|
||||
cfg.message_post(
|
||||
body=_('Part <b>%s</b>: %s') % (rec.name, body),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _compute_sale_order_count(self):
|
||||
for part in self:
|
||||
part.sale_order_count = self.env['sale.order'].search_count(
|
||||
@@ -215,6 +384,10 @@ class FpPartCatalog(models.Model):
|
||||
bbox_dims = None
|
||||
method = 'unknown'
|
||||
|
||||
is_manifold = None
|
||||
hole_count = 0
|
||||
hole_summary = ''
|
||||
|
||||
if ext in ('.step', '.stp', '.iges', '.igs', '.brep', '.brp'):
|
||||
# OCC (OpenCASCADE) for CAD formats -- exact B-Rep area
|
||||
try:
|
||||
@@ -224,6 +397,12 @@ class FpPartCatalog(models.Model):
|
||||
from OCP.BRepGProp import BRepGProp
|
||||
from OCP.Bnd import Bnd_Box
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
from OCP.BRepCheck import BRepCheck_Analyzer
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCP.GeomAbs import GeomAbs_Cylinder
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
|
||||
tmp.write(raw)
|
||||
@@ -238,18 +417,59 @@ class FpPartCatalog(models.Model):
|
||||
reader.TransferRoots()
|
||||
shape = reader.OneShape()
|
||||
|
||||
# Surface area (B-Rep exact)
|
||||
props = GProp_GProps()
|
||||
BRepGProp.SurfaceProperties_s(shape, props)
|
||||
area_mm2 = props.Mass()
|
||||
|
||||
# Volume
|
||||
vol_props = GProp_GProps()
|
||||
BRepGProp.VolumeProperties_s(shape, vol_props)
|
||||
volume_mm3 = vol_props.Mass()
|
||||
|
||||
# Bounding box
|
||||
bbox = Bnd_Box()
|
||||
BRepBndLib.Add_s(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
bbox_dims = (xmax - xmin, ymax - ymin, zmax - zmin)
|
||||
|
||||
# Manifold check (watertight)
|
||||
try:
|
||||
analyzer = BRepCheck_Analyzer(shape)
|
||||
is_manifold = bool(analyzer.IsValid())
|
||||
except Exception:
|
||||
is_manifold = None
|
||||
|
||||
# Cylindrical feature detection — counts holes, fillets, bores.
|
||||
# NOTE: fillets/rounded edges also appear as cylinders. We
|
||||
# cluster by diameter (rounded to 0.5 mm) and report the
|
||||
# breakdown so the estimator can identify real holes vs fillets.
|
||||
try:
|
||||
diameters = []
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while explorer.More():
|
||||
try:
|
||||
face = TopoDS.Face_s(explorer.Current())
|
||||
surf = BRepAdaptor_Surface(face)
|
||||
if surf.GetType() == GeomAbs_Cylinder:
|
||||
cyl = surf.Cylinder()
|
||||
diameter_mm = cyl.Radius() * 2.0
|
||||
# Round to 0.5 mm clusters (fillets ~0.5-3mm, holes ~3mm+)
|
||||
diameters.append(round(diameter_mm * 2) / 2)
|
||||
except Exception:
|
||||
pass
|
||||
explorer.Next()
|
||||
if diameters:
|
||||
from collections import Counter
|
||||
counts = Counter(diameters)
|
||||
hole_count = sum(counts.values())
|
||||
# Sort by diameter ascending, format
|
||||
parts = ['%d× Ø%.1fmm' % (n, d)
|
||||
for d, n in sorted(counts.items())]
|
||||
hole_summary = ', '.join(parts)
|
||||
except Exception as he:
|
||||
_logger.warning('Cylindrical feature detection failed: %s', he)
|
||||
|
||||
method = 'occ_brep'
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
@@ -283,7 +503,28 @@ class FpPartCatalog(models.Model):
|
||||
self.surface_area_uom = 'sq_in'
|
||||
self.geometry_source = '3d_model'
|
||||
|
||||
# Store extracted geometry on part catalog (triggers computed fields)
|
||||
self.volume_mm3 = round(volume_mm3, 2)
|
||||
if bbox_dims:
|
||||
self.bbox_length_mm = round(bbox_dims[0], 2)
|
||||
self.bbox_width_mm = round(bbox_dims[1], 2)
|
||||
self.bbox_height_mm = round(bbox_dims[2], 2)
|
||||
if is_manifold is not None:
|
||||
self.is_manifold = is_manifold
|
||||
self.hole_count = hole_count
|
||||
self.hole_summary = hole_summary
|
||||
|
||||
msg = '%.4f sq in (%.2f mm\u00b2) via %s' % (area_sqin, area_mm2, method)
|
||||
_logger.info('Part %s: surface area = %s', self.name, msg)
|
||||
return {'message': msg, 'area_sqin': area_sqin, 'area_mm2': area_mm2,
|
||||
'volume_mm3': volume_mm3, 'bbox': bbox_dims}
|
||||
if hole_count:
|
||||
msg += ' | %d holes' % hole_count
|
||||
_logger.info('Part %s: %s', self.name, msg)
|
||||
return {
|
||||
'message': msg,
|
||||
'area_sqin': area_sqin,
|
||||
'area_mm2': area_mm2,
|
||||
'volume_mm3': volume_mm3,
|
||||
'bbox': bbox_dims,
|
||||
'is_manifold': is_manifold,
|
||||
'hole_count': hole_count,
|
||||
'hole_summary': hole_summary,
|
||||
}
|
||||
|
||||
@@ -40,6 +40,88 @@ class FpQuoteConfigurator(models.Model):
|
||||
string='3D Model',
|
||||
readonly=True,
|
||||
)
|
||||
drawing_attachment_ids = fields.Many2many(
|
||||
related='part_catalog_id.drawing_attachment_ids',
|
||||
string='Drawings',
|
||||
readonly=True,
|
||||
)
|
||||
# -- Auto-extracted geometry from part catalog (read-only on configurator) --
|
||||
bbox_summary_in = fields.Char(
|
||||
related='part_catalog_id.bbox_summary_in', string='Dimensions (in)',
|
||||
readonly=True,
|
||||
)
|
||||
material_weight_kg = fields.Float(
|
||||
related='part_catalog_id.material_weight_kg', string='Weight (kg)',
|
||||
readonly=True,
|
||||
)
|
||||
hole_count = fields.Integer(
|
||||
related='part_catalog_id.hole_count', string='Holes',
|
||||
readonly=True,
|
||||
)
|
||||
hole_summary = fields.Char(
|
||||
related='part_catalog_id.hole_summary', string='Hole Summary',
|
||||
readonly=True,
|
||||
)
|
||||
is_manifold = fields.Boolean(
|
||||
related='part_catalog_id.is_manifold', string='Watertight',
|
||||
readonly=True,
|
||||
)
|
||||
masking_area_sqin = fields.Float(
|
||||
related='part_catalog_id.masking_area_sqin', string='Masking Area (sq in)',
|
||||
readonly=False, # allow editing via configurator
|
||||
)
|
||||
effective_area_sqin = fields.Float(
|
||||
related='part_catalog_id.effective_area_sqin', string='Effective Area (sq in)',
|
||||
readonly=True,
|
||||
)
|
||||
drawing_count = fields.Integer(
|
||||
string='Drawings',
|
||||
compute='_compute_drawing_count',
|
||||
)
|
||||
first_drawing_id = fields.Many2one(
|
||||
'ir.attachment', string='First Drawing',
|
||||
compute='_compute_first_drawing',
|
||||
inverse='_inverse_first_drawing',
|
||||
)
|
||||
|
||||
@api.depends('part_catalog_id.drawing_attachment_ids')
|
||||
def _compute_drawing_count(self):
|
||||
for rec in self:
|
||||
rec.drawing_count = len(rec.part_catalog_id.drawing_attachment_ids) if rec.part_catalog_id else 0
|
||||
|
||||
@api.depends('part_catalog_id.drawing_attachment_ids')
|
||||
def _compute_first_drawing(self):
|
||||
for rec in self:
|
||||
atts = rec.part_catalog_id.drawing_attachment_ids if rec.part_catalog_id else False
|
||||
rec.first_drawing_id = atts[0] if atts else False
|
||||
|
||||
def _inverse_first_drawing(self):
|
||||
"""When user clears or replaces the first drawing in the configurator,
|
||||
propagate that change to the part catalog's drawing list."""
|
||||
for rec in self:
|
||||
if not rec.part_catalog_id:
|
||||
continue
|
||||
atts = rec.part_catalog_id.drawing_attachment_ids
|
||||
current_first = atts[0] if atts else False
|
||||
new_first = rec.first_drawing_id
|
||||
# Cleared
|
||||
if current_first and not new_first:
|
||||
rec.part_catalog_id.sudo().write({
|
||||
'drawing_attachment_ids': [(3, current_first.id)],
|
||||
})
|
||||
# Replaced
|
||||
elif new_first and current_first and new_first.id != current_first.id:
|
||||
rec.part_catalog_id.sudo().write({
|
||||
'drawing_attachment_ids': [
|
||||
(3, current_first.id), (4, new_first.id),
|
||||
],
|
||||
})
|
||||
# Added (no current first, new value set)
|
||||
elif new_first and not current_first:
|
||||
rec.part_catalog_id.sudo().write({
|
||||
'drawing_attachment_ids': [(4, new_first.id)],
|
||||
})
|
||||
|
||||
# -- Quick file upload (creates/updates part catalog automatically) --
|
||||
upload_3d_file = fields.Binary(
|
||||
string='Upload 3D File',
|
||||
@@ -54,6 +136,27 @@ class FpQuoteConfigurator(models.Model):
|
||||
)
|
||||
upload_drawing_filename = fields.Char(string='Drawing Filename')
|
||||
|
||||
# -- RFQ / PO document tracking (from the beginning of the quote) --
|
||||
rfq_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='RFQ Document', copy=False, tracking=True,
|
||||
help="Customer's original Request for Quote document (PDF). "
|
||||
"Transferred to the sale order on quotation.",
|
||||
)
|
||||
po_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='Customer PO', copy=False, tracking=True,
|
||||
help='Customer PO document if already received. '
|
||||
'Transferred to the sale order on quotation.',
|
||||
)
|
||||
po_number_preliminary = fields.Char(
|
||||
string='PO Number', copy=False, tracking=True,
|
||||
help='Customer PO number if already known. '
|
||||
'Transferred to the sale order on quotation.',
|
||||
)
|
||||
upload_rfq_file = fields.Binary(string='Upload RFQ', attachment=False)
|
||||
upload_rfq_filename = fields.Char(string='RFQ Filename')
|
||||
upload_po_file = fields.Binary(string='Upload PO', attachment=False)
|
||||
upload_po_filename = fields.Char(string='PO Filename')
|
||||
|
||||
coating_config_id = fields.Many2one(
|
||||
'fp.coating.config', string='Coating Configuration', required=True,
|
||||
)
|
||||
@@ -345,6 +448,11 @@ class FpQuoteConfigurator(models.Model):
|
||||
'x_fc_coating_config_id': self.coating_config_id.id,
|
||||
'x_fc_rush_order': self.rush_order,
|
||||
'x_fc_delivery_method': self.delivery_method,
|
||||
# Transfer RFQ / PO documents from configurator (if any)
|
||||
'x_fc_rfq_attachment_id': self.rfq_attachment_id.id if self.rfq_attachment_id else False,
|
||||
'x_fc_po_attachment_id': self.po_attachment_id.id if self.po_attachment_id else False,
|
||||
'x_fc_po_number': self.po_number_preliminary or False,
|
||||
'x_fc_po_received': bool(self.po_attachment_id),
|
||||
'origin': self.name,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
@@ -430,6 +538,15 @@ class FpQuoteConfigurator(models.Model):
|
||||
self.surface_area = part.surface_area
|
||||
self.surface_area_uom = part.surface_area_uom
|
||||
|
||||
# Post to chatter so user sees confirmation (only if record is saved)
|
||||
if self.id and not isinstance(self.id, models.NewId):
|
||||
self.sudo().message_post(
|
||||
body=_('3D model attached: <b>%s</b> — surface area: %.4f %s') % (
|
||||
fname, self.surface_area, self.surface_area_uom or ''),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
# Clear the upload field (data is now on the part catalog)
|
||||
self.upload_3d_file = False
|
||||
self.upload_3d_filename = False
|
||||
@@ -449,7 +566,10 @@ class FpQuoteConfigurator(models.Model):
|
||||
})
|
||||
|
||||
if self.part_catalog_id:
|
||||
self.part_catalog_id.drawing_attachment_ids = [(4, att.id)]
|
||||
self.part_catalog_id.sudo().write({
|
||||
'drawing_attachment_ids': [(4, att.id)],
|
||||
})
|
||||
part = self.part_catalog_id
|
||||
else:
|
||||
import os
|
||||
part_name = os.path.splitext(fname)[0].replace('_', ' ').replace('-', ' ').title()
|
||||
@@ -461,9 +581,74 @@ class FpQuoteConfigurator(models.Model):
|
||||
})
|
||||
self.part_catalog_id = part.id
|
||||
|
||||
# Post to chatter so user sees confirmation (only if record is saved)
|
||||
if self.id and not isinstance(self.id, models.NewId):
|
||||
self.sudo().message_post(
|
||||
body=_('Drawing attached: <b>%s</b> (linked to part %s)') % (
|
||||
fname, part.name),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
self.upload_drawing = False
|
||||
self.upload_drawing_filename = False
|
||||
|
||||
@api.onchange('upload_rfq_file')
|
||||
def _onchange_upload_rfq_file(self):
|
||||
"""When an RFQ file is uploaded, create attachment + link it."""
|
||||
if not self.upload_rfq_file:
|
||||
return
|
||||
fname = self.upload_rfq_filename or 'rfq.pdf'
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_rfq_file,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
self.rfq_attachment_id = att.id
|
||||
self.upload_rfq_file = False
|
||||
self.upload_rfq_filename = False
|
||||
|
||||
@api.onchange('upload_po_file')
|
||||
def _onchange_upload_po_file(self):
|
||||
"""When a PO file is uploaded, create attachment + link it."""
|
||||
if not self.upload_po_file:
|
||||
return
|
||||
fname = self.upload_po_filename or 'po.pdf'
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_po_file,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
self.po_attachment_id = att.id
|
||||
self.upload_po_file = False
|
||||
self.upload_po_filename = False
|
||||
|
||||
def action_view_rfq(self):
|
||||
self.ensure_one()
|
||||
if not self.rfq_attachment_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_pdf_preview_open',
|
||||
'params': {
|
||||
'attachment_id': self.rfq_attachment_id.id,
|
||||
'title': _('RFQ — %s') % (self.rfq_attachment_id.name or ''),
|
||||
},
|
||||
}
|
||||
|
||||
def action_view_po(self):
|
||||
self.ensure_one()
|
||||
if not self.po_attachment_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_pdf_preview_open',
|
||||
'params': {
|
||||
'attachment_id': self.po_attachment_id.id,
|
||||
'title': _('PO — %s') % (self.po_attachment_id.name or ''),
|
||||
},
|
||||
}
|
||||
|
||||
def action_recalculate_price(self):
|
||||
"""Recalculate surface area from 3D model and recompute price."""
|
||||
self.ensure_one()
|
||||
@@ -495,16 +680,18 @@ class FpQuoteConfigurator(models.Model):
|
||||
self.write({'state': 'draft'})
|
||||
|
||||
def action_open_3d_fullscreen(self):
|
||||
"""Open the 3D model viewer in a new browser tab (full screen)."""
|
||||
"""Open the 3D model viewer in a full-screen dialog (same window)."""
|
||||
self.ensure_one()
|
||||
att = self.model_attachment_id
|
||||
if not att:
|
||||
return
|
||||
url = f'/fp/3d-viewer?id={att.id}&name={att.name}'
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': url,
|
||||
'target': 'new',
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_3d_viewer_open',
|
||||
'params': {
|
||||
'attachment_id': att.id,
|
||||
'name': att.name or '',
|
||||
},
|
||||
}
|
||||
|
||||
def action_view_sale_order(self):
|
||||
@@ -526,3 +713,27 @@ class FpQuoteConfigurator(models.Model):
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_view_drawings(self):
|
||||
"""Open the first drawing in the PDF preview dialog (matches RFQ/PO behavior)."""
|
||||
self.ensure_one()
|
||||
if self.first_drawing_id:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_pdf_preview_open',
|
||||
'params': {
|
||||
'attachment_id': self.first_drawing_id.id,
|
||||
'title': _('Drawing — %s') % (self.first_drawing_id.name or ''),
|
||||
},
|
||||
}
|
||||
# No drawing: fall back to part catalog
|
||||
if not self.part_catalog_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Drawings — %s') % self.part_catalog_id.name,
|
||||
'res_model': 'fp.part.catalog',
|
||||
'res_id': self.part_catalog_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
@@ -13,8 +13,18 @@ class SaleOrder(models.Model):
|
||||
x_fc_part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
|
||||
x_fc_coating_config_id = fields.Many2one('fp.coating.config', string='Coating Configuration')
|
||||
x_fc_po_number = fields.Char(string='Customer PO #', tracking=True)
|
||||
x_fc_po_attachment_id = fields.Many2one('ir.attachment', string='PO Document')
|
||||
x_fc_po_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='PO Document', tracking=True,
|
||||
)
|
||||
x_fc_po_received = fields.Boolean(string='PO Received', tracking=True)
|
||||
x_fc_rfq_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='RFQ Document', tracking=True,
|
||||
help="Customer's original Request for Quote document.",
|
||||
)
|
||||
upload_rfq_file = fields.Binary(string='Upload RFQ', attachment=False)
|
||||
upload_rfq_filename = fields.Char(string='RFQ Filename')
|
||||
upload_po_file = fields.Binary(string='Upload PO', attachment=False)
|
||||
upload_po_filename = fields.Char(string='PO Filename')
|
||||
x_fc_po_override = fields.Boolean(string='PO Override',
|
||||
help='Manager override — proceed without formal PO (handshake deal).')
|
||||
x_fc_po_override_reason = fields.Text(string='Override Reason')
|
||||
@@ -36,3 +46,61 @@ class SaleOrder(models.Model):
|
||||
('received', 'Received'), ('inspected', 'Inspected')],
|
||||
string='Receiving Status', default='not_received', tracking=True,
|
||||
)
|
||||
|
||||
@api.onchange('upload_rfq_file')
|
||||
def _onchange_upload_rfq_file(self):
|
||||
"""Create attachment from uploaded binary and link it."""
|
||||
if not self.upload_rfq_file:
|
||||
return
|
||||
fname = self.upload_rfq_filename or 'rfq.pdf'
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_rfq_file,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
self.x_fc_rfq_attachment_id = att.id
|
||||
self.upload_rfq_file = False
|
||||
self.upload_rfq_filename = False
|
||||
|
||||
@api.onchange('upload_po_file')
|
||||
def _onchange_upload_po_file(self):
|
||||
"""Create attachment from uploaded binary, link it, and mark PO received."""
|
||||
if not self.upload_po_file:
|
||||
return
|
||||
fname = self.upload_po_filename or 'po.pdf'
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_po_file,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
self.x_fc_po_attachment_id = att.id
|
||||
if not self.x_fc_po_received:
|
||||
self.x_fc_po_received = True
|
||||
self.upload_po_file = False
|
||||
self.upload_po_filename = False
|
||||
|
||||
def action_view_rfq(self):
|
||||
self.ensure_one()
|
||||
if not self.x_fc_rfq_attachment_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_pdf_preview_open',
|
||||
'params': {
|
||||
'attachment_id': self.x_fc_rfq_attachment_id.id,
|
||||
'title': 'RFQ — %s' % (self.x_fc_rfq_attachment_id.name or ''),
|
||||
},
|
||||
}
|
||||
|
||||
def action_view_po(self):
|
||||
self.ensure_one()
|
||||
if not self.x_fc_po_attachment_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_pdf_preview_open',
|
||||
'params': {
|
||||
'attachment_id': self.x_fc_po_attachment_id.id,
|
||||
'title': 'PO — %s' % (self.x_fc_po_attachment_id.name or ''),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,10 +18,26 @@ html,body{width:100%;height:100%;overflow:hidden;font-family:system-ui,-apple-sy
|
||||
.fmt-stl{background:rgba(76,175,80,.15);color:#2e7d32}
|
||||
.fmt-brep{background:rgba(255,152,0,.15);color:#e65100}
|
||||
.fmt-other{background:rgba(158,158,158,.15);color:#616161}
|
||||
#toolbar{position:absolute;top:10px;left:10px;display:flex;gap:4px;z-index:100;flex-wrap:wrap;background:rgba(255,255,255,.85);padding:4px;border-radius:6px;backdrop-filter:blur(4px);box-shadow:0 1px 3px rgba(0,0,0,.1)}
|
||||
#toolbar button{background:#fff;border:1px solid #ced4da;border-radius:4px;padding:4px 8px;font-size:11px;font-weight:500;cursor:pointer;color:#495057;transition:all .15s;min-width:40px}
|
||||
#toolbar button:hover{background:#0d6efd;color:#fff;border-color:#0d6efd}
|
||||
#toolbar .btn-divider{width:1px;background:#dee2e6;margin:2px 4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="viewer-container"></div>
|
||||
<div id="toolbar">
|
||||
<button onclick="setView('top')" title="Top view">Top</button>
|
||||
<button onclick="setView('bottom')" title="Bottom view">Btm</button>
|
||||
<button onclick="setView('front')" title="Front view">Front</button>
|
||||
<button onclick="setView('back')" title="Back view">Back</button>
|
||||
<button onclick="setView('left')" title="Left view">Left</button>
|
||||
<button onclick="setView('right')" title="Right view">Right</button>
|
||||
<button onclick="setView('iso')" title="Isometric view">Iso</button>
|
||||
<span class="btn-divider"></span>
|
||||
<button onclick="fitToView()" title="Fit to view">Fit</button>
|
||||
<button onclick="takeScreenshot()" title="Take screenshot (PNG)">📷</button>
|
||||
</div>
|
||||
<div id="format-badge"></div>
|
||||
<div id="loading"><div class="spinner"></div><div id="loading-msg">Loading 3D model...</div></div>
|
||||
<div id="error"></div>
|
||||
@@ -115,6 +131,106 @@ html,body{width:100%;height:100%;overflow:hidden;font-family:system-ui,-apple-sy
|
||||
.catch(function(err) {
|
||||
showError('Failed to load model: ' + err.message);
|
||||
});
|
||||
|
||||
// ---- View preset functions (Top/Front/Side/Iso) ----
|
||||
// Online3DViewer's internal viewer exposes a Camera object we can manipulate.
|
||||
window.setView = function(view) {
|
||||
try {
|
||||
const v = viewer.GetViewer();
|
||||
if (!v) return;
|
||||
const camera = v.GetCamera();
|
||||
if (!camera) return;
|
||||
// Compute distance from current camera to keep zoom roughly consistent
|
||||
const eye = camera.eye;
|
||||
const center = camera.center;
|
||||
const dx = eye.x - center.x, dy = eye.y - center.y, dz = eye.z - center.z;
|
||||
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) || 100;
|
||||
let newEye, newUp;
|
||||
switch (view) {
|
||||
case 'top':
|
||||
newEye = new OV.Coord3D(center.x, center.y, center.z + dist);
|
||||
newUp = new OV.Coord3D(0, 1, 0);
|
||||
break;
|
||||
case 'bottom':
|
||||
newEye = new OV.Coord3D(center.x, center.y, center.z - dist);
|
||||
newUp = new OV.Coord3D(0, 1, 0);
|
||||
break;
|
||||
case 'front':
|
||||
newEye = new OV.Coord3D(center.x, center.y - dist, center.z);
|
||||
newUp = new OV.Coord3D(0, 0, 1);
|
||||
break;
|
||||
case 'back':
|
||||
newEye = new OV.Coord3D(center.x, center.y + dist, center.z);
|
||||
newUp = new OV.Coord3D(0, 0, 1);
|
||||
break;
|
||||
case 'left':
|
||||
newEye = new OV.Coord3D(center.x - dist, center.y, center.z);
|
||||
newUp = new OV.Coord3D(0, 0, 1);
|
||||
break;
|
||||
case 'right':
|
||||
newEye = new OV.Coord3D(center.x + dist, center.y, center.z);
|
||||
newUp = new OV.Coord3D(0, 0, 1);
|
||||
break;
|
||||
case 'iso':
|
||||
default:
|
||||
const d = dist / Math.sqrt(3);
|
||||
newEye = new OV.Coord3D(center.x + d, center.y - d, center.z + d);
|
||||
newUp = new OV.Coord3D(0, 0, 1);
|
||||
break;
|
||||
}
|
||||
const newCam = new OV.Camera(newEye, center, newUp, camera.fov);
|
||||
v.SetCamera(newCam);
|
||||
v.Render();
|
||||
} catch(e) {
|
||||
console.warn('setView failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
window.fitToView = function() {
|
||||
try {
|
||||
const v = viewer.GetViewer();
|
||||
if (v && v.FitSphereToWindow) {
|
||||
// FitSphereToWindow uses the model's bounding sphere
|
||||
v.FitSphereToWindow(v.GetBoundingSphere(() => true), false);
|
||||
v.Render();
|
||||
}
|
||||
} catch(e) {
|
||||
console.warn('fitToView failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
window.takeScreenshot = function() {
|
||||
try {
|
||||
const v = viewer.GetViewer();
|
||||
if (!v) return;
|
||||
// Get the renderer's canvas and convert to PNG
|
||||
const canvas = v.GetCanvas ? v.GetCanvas() : null;
|
||||
if (!canvas) {
|
||||
// Fallback: find canvas inside container
|
||||
const c = container.querySelector('canvas');
|
||||
if (!c) return;
|
||||
downloadCanvas(c);
|
||||
return;
|
||||
}
|
||||
downloadCanvas(canvas);
|
||||
} catch(e) {
|
||||
console.warn('screenshot failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
function downloadCanvas(canvas) {
|
||||
canvas.toBlob(function(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
a.download = (fileName.replace(/\.[^.]+$/, '') || 'model') + '-' + stamp + '.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 'image/png');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
|
||||
export class Fp3dViewer extends Component {
|
||||
static template = "fusion_plating_configurator.Fp3dViewer";
|
||||
@@ -58,3 +59,60 @@ registry.category("fields").add("fp_3d_preview", {
|
||||
component: Fp3dViewer,
|
||||
supportedTypes: ["many2one"],
|
||||
});
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// 3D Viewer Dialog component (full-screen embedded viewer)
|
||||
// =============================================================================
|
||||
export class Fp3dViewerDialog extends Component {
|
||||
static template = "fusion_plating_configurator.Fp3dViewerDialog";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
attachmentId: Number,
|
||||
name: { type: String, optional: true },
|
||||
close: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({ isMaximized: true });
|
||||
}
|
||||
|
||||
get iframeSrc() {
|
||||
const name = encodeURIComponent(this.props.name || "");
|
||||
return `/fp/3d-viewer?id=${this.props.attachmentId}&name=${name}`;
|
||||
}
|
||||
|
||||
get dialogSize() {
|
||||
return this.state.isMaximized ? "fullscreen" : "xl";
|
||||
}
|
||||
|
||||
get frameStyle() {
|
||||
if (this.state.isMaximized) {
|
||||
return "height: calc(98vh - 100px) !important;";
|
||||
}
|
||||
return "height: calc(85vh - 100px) !important;";
|
||||
}
|
||||
|
||||
toggleSize() {
|
||||
this.state.isMaximized = !this.state.isMaximized;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("dialog").add("Fp3dViewerDialog", Fp3dViewerDialog);
|
||||
|
||||
|
||||
// Client action handler — opens the 3D viewer in a dialog within the same window.
|
||||
// Triggered by Python returning:
|
||||
// { type: 'ir.actions.client', tag: 'fp_3d_viewer_open',
|
||||
// params: { attachment_id: N, name: "..." } }
|
||||
function fp3dViewerOpenAction(env, action) {
|
||||
const params = action.params || {};
|
||||
if (!params.attachment_id) return Promise.resolve();
|
||||
env.services.dialog.add(Fp3dViewerDialog, {
|
||||
attachmentId: params.attachment_id,
|
||||
name: params.name || "",
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_3d_viewer_open", fp3dViewerOpenAction);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/** @odoo-module **/
|
||||
// Fusion Plating -- PDF Drawing Preview Widget
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1
|
||||
//
|
||||
// Custom many2many_binary widget that opens PDFs in the fusion_pdf_preview
|
||||
// dialog instead of downloading them.
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import {
|
||||
Many2ManyBinaryField,
|
||||
many2ManyBinaryField,
|
||||
} from "@web/views/fields/many2many_binary/many2many_binary_field";
|
||||
|
||||
export class FpPdfPreviewBinary extends Many2ManyBinaryField {
|
||||
static template = "fusion_plating_configurator.FpPdfPreviewBinary";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.dialogService = useService("dialog");
|
||||
}
|
||||
|
||||
onFileClick(ev, file) {
|
||||
const isPdf = (file.mimetype === "application/pdf") ||
|
||||
(file.name || "").toLowerCase().endsWith(".pdf");
|
||||
const dialogs = registry.category("dialog");
|
||||
|
||||
if (isPdf && dialogs.contains("PDFViewerDialog")) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const PDFViewerDialog = dialogs.get("PDFViewerDialog");
|
||||
const url = `/web/content/${file.id}?download=false`;
|
||||
this.dialogService.add(PDFViewerDialog, {
|
||||
url: url,
|
||||
title: file.name || "Drawing",
|
||||
reportName: "",
|
||||
recordIds: "",
|
||||
modelName: "ir.attachment",
|
||||
});
|
||||
}
|
||||
// For non-PDF or when preview not available, default browser behavior
|
||||
// (the <a href> with download attribute) kicks in because we don't
|
||||
// prevent default.
|
||||
}
|
||||
}
|
||||
|
||||
export const fpPdfPreviewBinary = {
|
||||
...many2ManyBinaryField,
|
||||
component: FpPdfPreviewBinary,
|
||||
};
|
||||
|
||||
registry.category("fields").add("fp_pdf_preview_binary", fpPdfPreviewBinary);
|
||||
|
||||
|
||||
// Client action handler: open a PDF attachment in the fusion_pdf_preview dialog.
|
||||
// Triggered by Python methods returning:
|
||||
// { type: 'ir.actions.client', tag: 'fp_pdf_preview_open',
|
||||
// params: { attachment_id: N, title: "..." } }
|
||||
function fpPdfPreviewOpenAction(env, action) {
|
||||
const params = action.params || {};
|
||||
const attId = params.attachment_id;
|
||||
if (!attId) return Promise.resolve();
|
||||
const dialogs = registry.category("dialog");
|
||||
const PDFViewerDialog = dialogs.contains("PDFViewerDialog") ? dialogs.get("PDFViewerDialog") : null;
|
||||
if (!PDFViewerDialog) {
|
||||
window.open(`/web/content/${attId}?download=false`, '_blank');
|
||||
return Promise.resolve();
|
||||
}
|
||||
const url = `/web/content/${attId}?download=false`;
|
||||
env.services.dialog.add(PDFViewerDialog, {
|
||||
url: url,
|
||||
title: params.title || 'Document',
|
||||
reportName: '',
|
||||
recordIds: '',
|
||||
modelName: 'ir.attachment',
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_pdf_preview_open", fpPdfPreviewOpenAction);
|
||||
@@ -0,0 +1,80 @@
|
||||
/** @odoo-module **/
|
||||
// Fusion Plating -- Inline PDF Preview field widget
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1
|
||||
//
|
||||
// Field widget for Many2one(ir.attachment) fields that embeds the
|
||||
// PDF.js viewer inline at a fixed height (one page at a time).
|
||||
// A "Full Screen" button below opens the fusion_pdf_preview dialog.
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
|
||||
export class FpPdfInlinePreview extends Component {
|
||||
static template = "fusion_plating_configurator.FpPdfInlinePreview";
|
||||
static props = { ...standardFieldProps };
|
||||
|
||||
setup() {
|
||||
this.dialogService = useService("dialog");
|
||||
this.state = useState({ hasAttachment: false, iframeSrc: "", attId: 0, name: "" });
|
||||
this._updateState();
|
||||
}
|
||||
|
||||
get rawValue() {
|
||||
return this.props.record.data[this.props.name];
|
||||
}
|
||||
|
||||
_updateState() {
|
||||
const v = this.rawValue;
|
||||
let attId = 0;
|
||||
let name = "";
|
||||
if (v) {
|
||||
if (Array.isArray(v)) {
|
||||
attId = v[0] || 0;
|
||||
name = v[1] || "";
|
||||
} else if (typeof v === "object" && v.id) {
|
||||
attId = v.id;
|
||||
name = v.display_name || "";
|
||||
} else if (typeof v === "number") {
|
||||
attId = v;
|
||||
}
|
||||
}
|
||||
this.state.hasAttachment = !!attId;
|
||||
this.state.attId = attId;
|
||||
this.state.name = name;
|
||||
if (attId) {
|
||||
const fileUrl = `/web/content/${attId}?download=false`;
|
||||
// PDF.js URL params: zoom=page-fit, no thumbs sidebar, single-page mode
|
||||
this.state.iframeSrc =
|
||||
`/fusion_pdf_preview/static/lib/pdfjs/web/viewer.html` +
|
||||
`?file=${encodeURIComponent(fileUrl)}` +
|
||||
`#zoom=page-fit&pagemode=none&scrollmode=3`;
|
||||
}
|
||||
}
|
||||
|
||||
onPatched() {
|
||||
this._updateState();
|
||||
}
|
||||
|
||||
openFullScreen() {
|
||||
if (!this.state.attId) return;
|
||||
const dialogs = registry.category("dialog");
|
||||
if (!dialogs.contains("PDFViewerDialog")) return;
|
||||
const PDFViewerDialog = dialogs.get("PDFViewerDialog");
|
||||
const url = `/web/content/${this.state.attId}?download=false`;
|
||||
this.dialogService.add(PDFViewerDialog, {
|
||||
url: url,
|
||||
title: this.state.name || "Drawing",
|
||||
reportName: "",
|
||||
recordIds: "",
|
||||
modelName: "ir.attachment",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("fp_pdf_inline_preview", {
|
||||
component: FpPdfInlinePreview,
|
||||
supportedTypes: ["many2one"],
|
||||
});
|
||||
@@ -5,13 +5,22 @@
|
||||
// =============================================================================
|
||||
|
||||
// -- Configurator two-column layout: 3/4 fields + 1/4 preview --
|
||||
// When the preview column is hidden (no 3D model AND no drawings), the
|
||||
// fields column expands to full width via the :has() selector below.
|
||||
.o_fp_cfg_layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
grid-template-columns: 1fr 380px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
// Full width when right column has no visible content
|
||||
.o_fp_cfg_layout:has(> .o_fp_cfg_preview.o_invisible_modifier),
|
||||
.o_fp_cfg_layout:has(> .o_fp_cfg_preview[style*="display: none"]),
|
||||
.o_fp_cfg_layout:has(> .o_fp_cfg_preview[style*="display:none"]) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.o_fp_cfg_fields {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -19,6 +28,18 @@
|
||||
.o_fp_cfg_preview {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
|
||||
// Force all field widgets (3D viewer, Html drawing preview) to be
|
||||
// block-level + full width so the 3D and PDF iframes match exactly.
|
||||
.o_field_widget,
|
||||
> div > .o_field_widget {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
iframe {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive: stack on narrow screens
|
||||
@@ -50,14 +71,73 @@
|
||||
|
||||
.o_fp_3d_iframe {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
height: 450px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #f0f2f5;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Inside the preview column, make iframe taller
|
||||
// Inside the preview column: same height as the PDF preview iframe
|
||||
.o_fp_cfg_preview .o_fp_3d_iframe {
|
||||
height: 600px;
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
// -- 3D Viewer Dialog (full-screen modal) --
|
||||
.o_fp_3d_dialog {
|
||||
.modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_3d_dialog_body {
|
||||
width: 100%;
|
||||
background-color: #f0f2f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.o_fp_3d_dialog_iframe {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
.o_fp_3d_dialog_actions {
|
||||
padding: 8px 12px;
|
||||
text-align: right;
|
||||
border-top: 1px solid var(--bs-border-color, #dee2e6);
|
||||
background-color: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
// -- Inline PDF preview widget (fp_pdf_inline_preview) --
|
||||
.o_fp_pdf_inline_root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.o_fp_pdf_inline_frame_wrap {
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
.o_fp_pdf_inline_iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.o_fp_pdf_inline_placeholder {
|
||||
border: 2px dashed $border-color;
|
||||
border-radius: 0.5rem;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
@@ -18,4 +18,33 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_plating_configurator.Fp3dViewerDialog">
|
||||
<Dialog title.translate="3D Model Viewer"
|
||||
size="dialogSize"
|
||||
contentClass="'o_fp_3d_dialog'"
|
||||
footer="false">
|
||||
<div class="o_fp_3d_dialog_body">
|
||||
<iframe t-att-src="iframeSrc"
|
||||
t-att-style="frameStyle"
|
||||
class="o_fp_3d_dialog_iframe"
|
||||
frameborder="0"
|
||||
allowfullscreen="true"/>
|
||||
</div>
|
||||
<div class="o_fp_3d_dialog_actions">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
t-on-click="toggleSize">
|
||||
<i t-att-class="state.isMaximized ? 'fa fa-compress me-1' : 'fa fa-expand me-1'"/>
|
||||
<t t-if="state.isMaximized">Restore</t>
|
||||
<t t-else="">Maximize</t>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-secondary ms-2"
|
||||
t-on-click="props.close">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_configurator.FpPdfPreviewBinary">
|
||||
<div t-attf-class="oe_fileupload {{props.className ? props.className : ''}}" aria-atomic="true">
|
||||
<div class="o_attachments">
|
||||
<t t-foreach="files" t-as="file" t-key="file_index">
|
||||
<t t-set="editable" t-value="!props.readonly"/>
|
||||
<t t-set="ext" t-value="getExtension(file)"/>
|
||||
<t t-set="url" t-value="getUrl(file.id)"/>
|
||||
<t t-set="isPdf" t-value="(file.mimetype === 'application/pdf') or (file.name and file.name.toLowerCase().endsWith('.pdf'))"/>
|
||||
<div t-attf-class="o_attachment o_attachment_many2many #{ editable ? 'o_attachment_editable' : '' }"
|
||||
t-att-title="file.name">
|
||||
<div class="o_attachment_wrap">
|
||||
<div class="o_image_box float-start"
|
||||
t-att-data-tooltip="isPdf ? 'Preview ' + file.name : 'Download ' + file.name">
|
||||
<a t-att-href="url"
|
||||
t-on-click="(ev) => this.onFileClick(ev, file)"
|
||||
t-att-download="isPdf ? undefined : ''"
|
||||
aria-label="Open">
|
||||
<img t-if="isImage(file)"
|
||||
class="o_preview_image o_hover object-fit-cover rounded align-baseline"
|
||||
t-attf-src="/web/image/{{ file.id }}"
|
||||
onerror="this.src = '/web/static/img/mimetypes/image.svg'"/>
|
||||
<span t-else="" class="o_image o_preview_image o_hover"
|
||||
t-att-data-mimetype="file.mimetype"
|
||||
t-att-data-ext="ext" role="img"/>
|
||||
</a>
|
||||
</div>
|
||||
<div class="caption">
|
||||
<a class="ml4"
|
||||
t-att-data-tooltip="isPdf ? 'Preview ' + file.name : 'Download ' + file.name"
|
||||
t-att-href="url"
|
||||
t-on-click="(ev) => this.onFileClick(ev, file)"
|
||||
t-att-download="isPdf ? undefined : ''"><t t-esc="file.name"/></a>
|
||||
</div>
|
||||
<div class="caption small">
|
||||
<a class="ml4 small text-uppercase"
|
||||
t-att-href="url"
|
||||
t-on-click="(ev) => this.onFileClick(ev, file)"
|
||||
t-att-download="isPdf ? undefined : ''">
|
||||
<b><t t-esc="ext"/></b>
|
||||
</a>
|
||||
</div>
|
||||
<div class="o_attachment_uploaded">
|
||||
<i class="text-success fa fa-check" role="img"
|
||||
aria-label="Uploaded" title="Uploaded"/>
|
||||
</div>
|
||||
<div t-if="editable" class="o_attachment_delete"
|
||||
t-on-click.stop="() => this.onFileRemove(file.id)">
|
||||
<span role="img" aria-label="Delete" title="Delete">×</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="!props.readonly and (!props.numberOfFiles or files.length < props.numberOfFiles)"
|
||||
class="oe_add">
|
||||
<FileInput acceptedFileExtensions="props.acceptedFileExtensions"
|
||||
multiUpload="true"
|
||||
onUpload.bind="onFileUploaded"
|
||||
resModel="props.record.resModel"
|
||||
resId="props.record.resId or 0">
|
||||
<button class="btn btn-secondary o_attach" data-tooltip="Attach">
|
||||
<span class="fa fa-paperclip" aria-label="Attach"/>
|
||||
<t t-esc="uploadText"/>
|
||||
</button>
|
||||
</FileInput>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_configurator.FpPdfInlinePreview">
|
||||
<div class="o_fp_pdf_inline_root">
|
||||
<t t-if="state.hasAttachment">
|
||||
<div class="o_fp_pdf_inline_frame_wrap">
|
||||
<iframe t-att-src="state.iframeSrc"
|
||||
class="o_fp_pdf_inline_iframe"
|
||||
frameborder="0"
|
||||
title="PDF Preview"/>
|
||||
</div>
|
||||
<div class="text-center mt-2">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
t-on-click="openFullScreen">
|
||||
<i class="fa fa-expand me-1"/>Full Screen
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="!state.hasAttachment">
|
||||
<div class="o_fp_pdf_inline_placeholder text-center text-muted p-4">
|
||||
<i class="fa fa-file-pdf-o fa-3x mb-2 d-block"/>
|
||||
<span>No PDF attached.</span>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -80,9 +80,35 @@
|
||||
invisible="not model_attachment_id"/>
|
||||
</div>
|
||||
<field name="surface_area_uom"/>
|
||||
<field name="masking_area_sqin"/>
|
||||
<field name="effective_area_sqin" readonly="1"/>
|
||||
<field name="weight"/>
|
||||
<field name="material_weight_kg" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<!-- Auto-extracted geometry from 3D model -->
|
||||
<group string="3D Model Analysis"
|
||||
invisible="not volume_mm3 and not bbox_summary_in and hole_count == 0">
|
||||
<group>
|
||||
<field name="bbox_summary_in" readonly="1"/>
|
||||
<field name="volume_mm3" readonly="1"/>
|
||||
<field name="bbox_length_mm" invisible="1"/>
|
||||
<field name="bbox_width_mm" invisible="1"/>
|
||||
<field name="bbox_height_mm" invisible="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="hole_count" readonly="1"/>
|
||||
<field name="hole_summary" readonly="1" invisible="not hole_summary"/>
|
||||
<field name="is_manifold" widget="boolean_toggle" readonly="1"/>
|
||||
</group>
|
||||
<div class="alert alert-warning mb-0"
|
||||
colspan="2"
|
||||
invisible="is_manifold or not model_attachment_id">
|
||||
<i class="fa fa-exclamation-triangle me-1"/>
|
||||
<strong>Geometry warning:</strong> 3D model is not watertight (manifold).
|
||||
This often indicates open shells or invalid surfaces. Review before quoting.
|
||||
</div>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Dimensions & Complexity" name="dimensions">
|
||||
<group>
|
||||
@@ -106,7 +132,7 @@
|
||||
<page string="Attachments" name="attachments">
|
||||
<group>
|
||||
<field name="model_attachment_id"/>
|
||||
<field name="drawing_attachment_ids" widget="many2many_binary"/>
|
||||
<field name="drawing_attachment_ids" widget="fp_pdf_preview_binary"/>
|
||||
</group>
|
||||
<div invisible="not model_attachment_id" class="mt-3">
|
||||
<field name="model_attachment_id" widget="fp_3d_preview" nolabel="1"/>
|
||||
|
||||
@@ -49,6 +49,33 @@
|
||||
invisible="not part_catalog_id">
|
||||
<field name="part_catalog_id" widget="statinfo" string="Part"/>
|
||||
</button>
|
||||
<button name="action_view_drawings"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-file-pdf-o"
|
||||
invisible="drawing_count == 0">
|
||||
<field name="drawing_count" widget="statinfo" string="Drawings"/>
|
||||
</button>
|
||||
<button name="action_view_rfq"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-envelope-o"
|
||||
invisible="not rfq_attachment_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value">1</span>
|
||||
<span class="o_stat_text">RFQ</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_po"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-file-o"
|
||||
invisible="not po_attachment_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value">1</span>
|
||||
<span class="o_stat_text">PO</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
@@ -65,16 +92,47 @@
|
||||
<field name="partner_id"/>
|
||||
<field name="part_catalog_id"/>
|
||||
<field name="coating_config_id"/>
|
||||
<!-- 3D File: upload before, filename + clear button after -->
|
||||
<field name="upload_3d_file" filename="upload_3d_filename"
|
||||
invisible="state != 'draft'"
|
||||
invisible="state != 'draft' or model_attachment_id"
|
||||
string="Attach 3D File"/>
|
||||
<field name="upload_3d_filename" invisible="1"/>
|
||||
<field name="model_attachment_id"
|
||||
string="3D Model"
|
||||
invisible="not model_attachment_id"
|
||||
readonly="state != 'draft'"/>
|
||||
<!-- Drawing: upload before, filename + clear button after -->
|
||||
<field name="upload_drawing" filename="upload_drawing_filename"
|
||||
invisible="state != 'draft'"
|
||||
invisible="state != 'draft' or drawing_count > 0"
|
||||
string="Attach Drawing"/>
|
||||
<field name="upload_drawing_filename" invisible="1"/>
|
||||
<field name="first_drawing_id"
|
||||
string="Drawing"
|
||||
invisible="drawing_count == 0"
|
||||
readonly="state != 'draft'"/>
|
||||
<field name="drawing_count" invisible="1"/>
|
||||
</group>
|
||||
<group string="Quantity & Options">
|
||||
<group string="RFQ / PO Documents">
|
||||
<field name="upload_rfq_file"
|
||||
filename="upload_rfq_filename"
|
||||
invisible="state != 'draft' or rfq_attachment_id"
|
||||
string="Attach RFQ"/>
|
||||
<field name="upload_rfq_filename" invisible="1"/>
|
||||
<field name="rfq_attachment_id"
|
||||
invisible="not rfq_attachment_id"
|
||||
readonly="state != 'draft'"/>
|
||||
<field name="upload_po_file"
|
||||
filename="upload_po_filename"
|
||||
invisible="state != 'draft' or po_attachment_id"
|
||||
string="Attach PO"/>
|
||||
<field name="upload_po_filename" invisible="1"/>
|
||||
<field name="po_attachment_id"
|
||||
invisible="not po_attachment_id"
|
||||
readonly="state != 'draft'"/>
|
||||
<field name="po_number_preliminary"
|
||||
string="PO Number"
|
||||
readonly="state != 'draft'"/>
|
||||
<separator string="Quantity & Options"/>
|
||||
<field name="quantity"/>
|
||||
<field name="batch_size"/>
|
||||
<field name="complexity"/>
|
||||
@@ -85,11 +143,42 @@
|
||||
<group string="Geometry">
|
||||
<field name="surface_area"/>
|
||||
<field name="surface_area_uom"/>
|
||||
<field name="masking_area_sqin"
|
||||
string="Masking Area (sq in)"/>
|
||||
<field name="effective_area_sqin"
|
||||
string="Effective Plating Area"
|
||||
readonly="1"/>
|
||||
<field name="thickness_requested"/>
|
||||
<field name="substrate_material"/>
|
||||
<field name="masking_zones"/>
|
||||
<field name="turnaround_days"/>
|
||||
</group>
|
||||
<group string="Auto from 3D"
|
||||
invisible="not part_catalog_id">
|
||||
<field name="bbox_summary_in"
|
||||
string="Dimensions"
|
||||
readonly="1"/>
|
||||
<field name="material_weight_kg"
|
||||
string="Weight (kg)"
|
||||
readonly="1"/>
|
||||
<field name="hole_count"
|
||||
string="Holes Detected"
|
||||
readonly="1"/>
|
||||
<field name="hole_summary"
|
||||
readonly="1"
|
||||
invisible="not hole_summary"/>
|
||||
<field name="is_manifold"
|
||||
widget="boolean_toggle"
|
||||
readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<div class="alert alert-warning"
|
||||
invisible="is_manifold or not part_catalog_id or not hole_count">
|
||||
<i class="fa fa-exclamation-triangle me-1"/>
|
||||
<strong>Warning:</strong> 3D model is not watertight.
|
||||
Surface area calculation may be inaccurate. Review the file before quoting.
|
||||
</div>
|
||||
<group>
|
||||
<group string="Delivery & Fees">
|
||||
<field name="delivery_method"/>
|
||||
<field name="shipping_fee"/>
|
||||
@@ -112,15 +201,35 @@
|
||||
</group>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: 3D preview (sticky) -->
|
||||
<div class="o_fp_cfg_preview" invisible="not model_attachment_id">
|
||||
<field name="model_attachment_id" widget="fp_3d_preview" nolabel="1"/>
|
||||
<div class="text-center mt-2">
|
||||
<button name="action_open_3d_fullscreen"
|
||||
string="Full Screen"
|
||||
type="object"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
icon="fa-expand"/>
|
||||
<!-- RIGHT COLUMN: 3D preview + Drawings preview (sticky) -->
|
||||
<div class="o_fp_cfg_preview"
|
||||
invisible="not model_attachment_id and drawing_count == 0">
|
||||
<!-- 3D viewer -->
|
||||
<div invisible="not model_attachment_id">
|
||||
<field name="model_attachment_id" widget="fp_3d_preview" nolabel="1"/>
|
||||
<div class="text-center mt-2">
|
||||
<button name="action_open_3d_fullscreen"
|
||||
string="Full Screen"
|
||||
type="object"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
icon="fa-expand"/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Drawings preview (custom OWL widget — fixed height, full screen button) -->
|
||||
<div invisible="drawing_count == 0" class="mt-3">
|
||||
<span class="o_form_label fw-bold text-muted small d-block mb-1">Drawing Preview</span>
|
||||
<field name="first_drawing_id"
|
||||
widget="fp_pdf_inline_preview"
|
||||
nolabel="1"
|
||||
readonly="1"/>
|
||||
<!-- Multi-drawing list shown only when more than one -->
|
||||
<div invisible="drawing_count < 2" class="mt-2">
|
||||
<span class="o_form_label fw-bold text-muted small d-block mb-1">All Drawings</span>
|
||||
<field name="drawing_attachment_ids"
|
||||
widget="fp_pdf_preview_binary"
|
||||
nolabel="1"
|
||||
readonly="1"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,28 @@
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
|
||||
<button name="action_view_rfq"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-envelope-o"
|
||||
invisible="not x_fc_rfq_attachment_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value">1</span>
|
||||
<span class="o_stat_text">RFQ</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_po"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-file-o"
|
||||
invisible="not x_fc_po_attachment_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value">1</span>
|
||||
<span class="o_stat_text">PO</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Plating" name="plating_tab">
|
||||
<group>
|
||||
@@ -20,9 +42,22 @@
|
||||
<field name="x_fc_part_catalog_id"/>
|
||||
<field name="x_fc_coating_config_id"/>
|
||||
</group>
|
||||
<group string="Customer PO">
|
||||
<group string="RFQ / PO">
|
||||
<field name="x_fc_po_number"/>
|
||||
<field name="x_fc_po_attachment_id"/>
|
||||
<field name="upload_rfq_file"
|
||||
filename="upload_rfq_filename"
|
||||
invisible="x_fc_rfq_attachment_id"
|
||||
string="Attach RFQ"/>
|
||||
<field name="upload_rfq_filename" invisible="1"/>
|
||||
<field name="x_fc_rfq_attachment_id"
|
||||
invisible="not x_fc_rfq_attachment_id"/>
|
||||
<field name="upload_po_file"
|
||||
filename="upload_po_filename"
|
||||
invisible="x_fc_po_attachment_id"
|
||||
string="Attach PO"/>
|
||||
<field name="upload_po_filename" invisible="1"/>
|
||||
<field name="x_fc_po_attachment_id"
|
||||
invisible="not x_fc_po_attachment_id"/>
|
||||
<field name="x_fc_po_received"/>
|
||||
<field name="x_fc_po_override"
|
||||
groups="fusion_plating.group_fusion_plating_manager"/>
|
||||
|
||||
Reference in New Issue
Block a user