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:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user