diff --git a/plugins/warranty/api/routes.py b/plugins/warranty/api/routes.py index 85fd0da..11011a4 100644 --- a/plugins/warranty/api/routes.py +++ b/plugins/warranty/api/routes.py @@ -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-, one per warranty, with the vendor's own +# filename kept alongside so a person recognises it later. +# ============================================================================= + +@warranty_bp.route('//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/', 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('//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') diff --git a/plugins/warranty/frontend/views/WarrantiesList.vue b/plugins/warranty/frontend/views/WarrantiesList.vue index fdbf34e..e208cc0 100644 --- a/plugins/warranty/frontend/views/WarrantiesList.vue +++ b/plugins/warranty/frontend/views/WarrantiesList.vue @@ -151,6 +151,21 @@ + +
+ +
+ {{ proofName }} + {{ proofName }} + +
+ + + Invoice, certificate or a saved email - up to 25MB. Downloading + needs a login, since it carries pricing and a service tag. + +
{{ error }}