Attach proof of cover to a warranty

A provider lookup answers whether a unit is covered. It does not produce the
invoice or the extended-warranty certificate, and a manually entered warranty
had nowhere to keep one - so the proof stayed in somebody's mailbox until they
left.

Two columns rather than one: the served URL of the stored document, and the
name the vendor sent it under, because "Dell invoice 4471.pdf" is what a person
recognises a year later and "warranty-12.pdf" is not. The download route sends
the original name back.

Authenticated in both directions, unlike an asset photo: an invoice carries
pricing and a service tag. One document per warranty, replacing any prior
extension so a re-upload as .pdf does not leave the old .png behind claiming to
be current. Capped at 25MB - a certificate is a document, not a disk image.

Office formats are allowed because purchase records genuinely arrive as .msg
and .xlsx, not only as PDFs.
This commit is contained in:
cproudlock
2026-08-12 11:45:40 -04:00
parent c28b02e45b
commit 2fce81f33f
5 changed files with 369 additions and 4 deletions

View File

@@ -5,11 +5,14 @@ never stored. Warranties link to assets many-to-many via warrantyassets, though
the common case is one warranty per asset.
"""
import glob
import os
from datetime import date, datetime, timedelta, timezone
from flask import Blueprint, request
from flask import Blueprint, request, current_app, send_from_directory
from flask_jwt_extended import jwt_required
from sqlalchemy.orm import joinedload
from werkzeug.utils import secure_filename
from shopdb.api import (
db, Asset,
@@ -22,6 +25,21 @@ from ..services import get_provider, ProviderNotConfigured, WarrantyLookupError
warranty_bp = Blueprint('warranty', __name__)
# What a proof of cover actually arrives as: a vendor PDF, a scan, or a
# screenshot of a portal page. Office formats are allowed because purchase
# records often arrive that way.
PROOF_EXTENSIONS = {'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.tif',
'.tiff', '.msg', '.eml', '.doc', '.docx', '.xls', '.xlsx'}
PROOF_URL_PREFIX = '/api/warranty/proof/'
# A certificate is a document, not a disk image. Anything past this is somebody
# attaching the wrong thing.
MAX_PROOF_BYTES = 25 * 1024 * 1024
def _proof_dir():
return os.path.join(current_app.instance_path, 'warrantyproofs')
def _parse_date(value):
"""Accept 'YYYY-MM-DD' (or None/empty) -> date or None."""
@@ -466,3 +484,100 @@ def _hostname(asset):
return None
computer = Computer.query.filter_by(assetid=asset.assetid).first()
return computer.hostname if computer else None
# =============================================================================
# Proof of cover
#
# Authenticated on the way in AND on the way out: an invoice carries prices and
# a service tag, so it is not something to serve openly the way an asset photo
# is. Stored as warranty-<id><ext>, one per warranty, with the vendor's own
# filename kept alongside so a person recognises it later.
# =============================================================================
@warranty_bp.route('/<int:warrantyid>/proof', methods=['POST'])
@jwt_required()
@require_permission('warranty.edit')
def upload_proof(warrantyid):
"""Upload (or replace) the proof-of-cover document for a warranty."""
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found',
http_code=404)
upload = request.files.get('file')
if not upload or not upload.filename:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
ext = os.path.splitext(upload.filename)[1].lower()
if ext not in PROOF_EXTENSIONS:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Unsupported document type {}. Allowed: {}'.format(
ext, ', '.join(sorted(PROOF_EXTENSIONS))))
# Seek rather than trust Content-Length: a chunked upload has no length
# header, and a client can understate the one it sends.
upload.stream.seek(0, os.SEEK_END)
size = upload.stream.tell()
upload.stream.seek(0)
if size > MAX_PROOF_BYTES:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Document is {:.0f}MB; the limit is {:.0f}MB.'.format(
size / 1048576, MAX_PROOF_BYTES / 1048576))
proofdir = _proof_dir()
os.makedirs(proofdir, exist_ok=True)
# One proof per warranty: clear any prior extension so a re-upload as .pdf
# does not leave the old .png behind claiming to be current.
for old in glob.glob(os.path.join(proofdir,
secure_filename(f'warranty-{warrantyid}') + '.*')):
os.remove(old)
filename = secure_filename(f'warranty-{warrantyid}{ext}')
upload.save(os.path.join(proofdir, filename))
warranty.proofurl = f'{PROOF_URL_PREFIX}{filename}'
warranty.prooffilename = upload.filename
db.session.commit()
return success_response(warranty.to_dict(), message='Proof uploaded')
@warranty_bp.route('/proof/<path:filename>', methods=['GET'])
@jwt_required()
@require_permission('warranty.view')
def serve_proof(filename):
"""Download a proof document.
Authenticated: an invoice carries pricing and a service tag. Sent as an
attachment under the vendor's original filename where we still have it, so
a download is recognisable rather than 'warranty-12.pdf'.
"""
warranty = Warranty.query.filter(
Warranty.proofurl == f'{PROOF_URL_PREFIX}{filename}').first()
downloadname = (warranty.prooffilename if warranty and warranty.prooffilename
else filename)
return send_from_directory(_proof_dir(), filename, as_attachment=True,
download_name=downloadname)
@warranty_bp.route('/<int:warrantyid>/proof', methods=['DELETE'])
@jwt_required()
@require_permission('warranty.edit')
def delete_proof(warrantyid):
"""Remove a warranty's proof document."""
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found',
http_code=404)
for old in glob.glob(os.path.join(_proof_dir(),
secure_filename(f'warranty-{warrantyid}') + '.*')):
os.remove(old)
warranty.proofurl = None
warranty.prooffilename = None
db.session.commit()
return success_response(warranty.to_dict(), message='Proof removed')

View File

@@ -151,6 +151,21 @@
<label>Notes</label>
<textarea v-model="form.notes" class="form-control" rows="2"></textarea>
</div>
<div class="form-group">
<label>Proof of cover</label>
<div v-if="proofName" class="proof-current">
<a v-if="proofUrl" :href="proofUrl" @click.prevent="downloadProof">{{ proofName }}</a>
<span v-else>{{ proofName }}</span>
<button type="button" class="btn btn-sm btn-secondary" @click="removeProof"
:disabled="busyProof">Remove</button>
</div>
<input type="file" class="form-control" :accept="PROOF_ACCEPT" @change="onProofPicked" />
<small class="muted">
Invoice, certificate or a saved email - up to 25MB. Downloading
needs a login, since it carries pricing and a service tag.
</small>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
@@ -167,7 +182,7 @@
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { colorStyle } from '@/utils/colorStyle'
import { warrantyApi, assetsApi, vendorsApi } from '@/api'
import api, { warrantyApi, assetsApi, vendorsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import MachineMapChip from '../components/MachineMapChip.vue'
@@ -315,7 +330,10 @@ function openModal(item = null) {
} else {
form.value = blankForm()
selectedAssets.value = []
proofName.value = ''
proofUrl.value = ''
}
pendingProof.value = null
assetQuery.value = ''
assetResults.value = []
error.value = ''
@@ -323,15 +341,75 @@ function openModal(item = null) {
}
function closeModal() { showModal.value = false; editing.value = null }
// Chosen before a new warranty exists, so it is held here and uploaded once
// there is an id to attach it to.
const pendingProof = ref(null)
const proofName = ref('')
const proofUrl = ref('')
const busyProof = ref(false)
const PROOF_ACCEPT = '.pdf,.png,.jpg,.jpeg,.gif,.webp,.tif,.tiff,.msg,.eml,.doc,.docx,.xls,.xlsx'
const MAX_PROOF_BYTES = 25 * 1024 * 1024
function onProofPicked(event) {
const file = event.target.files?.[0]
if (!file) return
if (file.size > MAX_PROOF_BYTES) {
error.value = `${file.name} is ${(file.size / 1048576).toFixed(0)}MB; the limit is 25MB.`
event.target.value = ''
return
}
error.value = ''
pendingProof.value = file
proofName.value = file.name
proofUrl.value = ''
}
async function removeProof() {
pendingProof.value = null
proofName.value = ''
proofUrl.value = ''
if (editing.value) {
busyProof.value = true
try { await warrantyApi.removeProof(editing.value.warrantyid) }
catch (err) { error.value = apiError(err, 'Failed to remove the document') }
finally { busyProof.value = false }
}
}
// The route is authenticated, so a plain href would 401. Fetch it through the
// client, which carries the token, then hand the blob to the browser.
async function downloadProof() {
try {
const response = await api.get(proofUrl.value, { responseType: 'blob' })
const url = URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = proofName.value || 'proof'
link.click()
URL.revokeObjectURL(url)
} catch (err) {
error.value = apiError(err, 'Failed to download the document')
}
}
async function save() {
error.value = ''
saving.value = true
try {
const payload = { ...form.value, assetids: selectedAssets.value.map(a => a.assetid) }
let warrantyid
if (editing.value) {
await warrantyApi.update(editing.value.warrantyid, payload)
warrantyid = editing.value.warrantyid
await warrantyApi.update(warrantyid, payload)
} else {
await warrantyApi.create(payload)
const created = await warrantyApi.create(payload)
warrantyid = created.data.data.warrantyid
}
// After the save: a new warranty has no id until it exists, and the upload
// keys on it. A failed upload leaves the saved record alone and reports.
if (pendingProof.value) {
await warrantyApi.uploadProof(warrantyid, pendingProof.value)
pendingProof.value = null
}
closeModal()
loadData()
@@ -390,6 +468,8 @@ async function refresh(w) {
.filters label { display: inline-flex; align-items: center; gap: 0.4rem; }
.filters .form-control { max-width: 320px; }
.result-count { color: var(--text-light); font-size: 0.85rem; }
.proof-current { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
.proof-current a { word-break: break-all; }
.servicelevel-cell { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0.9rem 0 0.2rem; }
.page-info { color: var(--text-light); font-size: 0.85rem; }

View File

@@ -0,0 +1,50 @@
"""Add warranties.proofurl / prooffilename (proof-of-cover document).
A provider lookup answers whether a unit is covered. It does not produce the
invoice or the extended-warranty certificate, and a manually entered warranty
had nowhere to keep one - so the proof lived in somebody's mailbox until they
left. Two columns: the served URL of the stored file, and the name the vendor
sent it under, which is what a person recognises months later.
Idempotent; downgrade drops both.
Revision ID: warranty0002proof
Revises: warranty0001anchor
"""
from alembic import op
import sqlalchemy as sa
revision = 'warranty0002proof'
down_revision = 'warranty0001anchor'
branch_labels = None
depends_on = None
_TABLE = 'warranties'
_COLUMNS = (('proofurl', sa.String(500)), ('prooffilename', sa.String(255)))
def _column_names(insp, table):
return {c['name'] for c in insp.get_columns(table)}
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if _TABLE not in insp.get_table_names():
return
existing = _column_names(insp, _TABLE)
for name, coltype in _COLUMNS:
if name not in existing:
op.add_column(_TABLE, sa.Column(name, coltype, nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if _TABLE not in insp.get_table_names():
return
existing = _column_names(insp, _TABLE)
for name, _ in _COLUMNS:
if name in existing:
op.drop_column(_TABLE, name)

View File

@@ -48,6 +48,14 @@ class Warranty(db.Model):
# When a provider lookup last refreshed this record.
lastcheckeddate = db.Column(db.DateTime)
notes = db.Column(db.Text)
# Proof of cover: the purchase invoice, the extended-warranty certificate,
# whatever the vendor sent. A provider lookup answers "is it covered"; this
# answers "prove it" months later, when the email it arrived in is gone.
# Stores the served URL, not the original filename - see the upload route.
proofurl = db.Column(db.String(500))
prooffilename = db.Column(db.String(255))
isactive = db.Column(db.Boolean, nullable=False, server_default='1')
links = db.relationship('WarrantyAsset', back_populates='warranty',
@@ -68,6 +76,8 @@ class Warranty(db.Model):
'enddate': self.enddate.isoformat() if self.enddate else None,
'lastcheckeddate': self.lastcheckeddate.isoformat() + 'Z' if self.lastcheckeddate else None,
'notes': self.notes,
'proofurl': self.proofurl,
'prooffilename': self.prooffilename,
'isactive': bool(self.isactive),
'status': status,
'statuscolor': STATUS_COLORS.get(status, STATUS_COLORS['unknown']),