- Backend: export template system with priority chain: client template > user template > global template > built-in default - New /api/export/template endpoints for any logged-in user (GET/POST/DELETE) - Admin endpoints for global and per-client export templates - detect_csv_template() auto-maps CSV headers to internal fields - Frontend: ExportTemplateEditor component (upload CSV → map columns → save) - AdminClientsPage: export template section per client card - SheetPage: ⚙ button next to "Export CSV" opens inline template editor Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
221 lines
8.1 KiB
Python
221 lines
8.1 KiB
Python
"""
|
|
CSV export — Activation Calendar format.
|
|
Supports custom export templates: client > user > global > built-in default.
|
|
"""
|
|
|
|
import csv
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
|
|
from quart import Blueprint, make_response, jsonify, request
|
|
|
|
from ..auth.middleware import auth_required, get_user_id
|
|
from ..sheets.manager import load_sheet_data, get_sheet_client_id
|
|
from ..config_runtime import server_config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
export_bp = Blueprint('export', __name__, url_prefix='/api/sheets')
|
|
|
|
# Internal field names as they appear in sheet row data
|
|
INTERNAL_FIELDS = [
|
|
'Number', 'Title', 'Status', 'Category', 'Media', 'Sub-media',
|
|
'Format', 'Supply date', 'Live date', 'Language', 'Country',
|
|
'Quantity', 'Destination', 'End date', 'Special instructions',
|
|
]
|
|
|
|
# Fields cleared on export unless template explicitly maps them
|
|
_CLEAR_BY_DEFAULT = {'Number', 'Destination', 'End date', 'Special instructions'}
|
|
|
|
_DEFAULT_TEMPLATE = [
|
|
{'header': 'Number', 'field': 'Number'},
|
|
{'header': 'Title', 'field': 'Title'},
|
|
{'header': 'Status', 'field': 'Status'},
|
|
{'header': 'Category', 'field': 'Category'},
|
|
{'header': 'Media', 'field': 'Media'},
|
|
{'header': 'Sub media', 'field': 'Sub-media'},
|
|
{'header': 'Destination', 'field': 'Destination'},
|
|
{'header': 'Format', 'field': 'Format'},
|
|
{'header': 'Supply date', 'field': 'Supply date'},
|
|
{'header': 'Live date', 'field': 'Live date'},
|
|
{'header': 'End date', 'field': 'End date'},
|
|
{'header': 'Special instructions', 'field': 'Special instructions'},
|
|
{'header': 'Language', 'field': 'Language'},
|
|
{'header': 'Country', 'field': 'Country'},
|
|
{'header': 'Quantity', 'field': 'Quantity'},
|
|
]
|
|
|
|
|
|
def _client_template_path(client_id: str) -> str:
|
|
return os.path.join(server_config.CLIENTS_DROPDOWNS_DIR, f'{client_id}_export.json')
|
|
|
|
|
|
def _user_template_path(user_id: str) -> str:
|
|
return os.path.join(server_config.USER_EXPORT_TEMPLATES_DIR, f'{user_id}.json')
|
|
|
|
|
|
def _load_json(path: str):
|
|
try:
|
|
with open(path) as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def load_export_template(client_id: str = None, user_id: str = None) -> list:
|
|
"""
|
|
Priority: client template → user template → global template → built-in default.
|
|
"""
|
|
if client_id:
|
|
t = _load_json(_client_template_path(client_id))
|
|
if t:
|
|
return t
|
|
if user_id:
|
|
t = _load_json(_user_template_path(user_id))
|
|
if t:
|
|
return t
|
|
t = _load_json(server_config.EXPORT_TEMPLATE_FILE)
|
|
if t:
|
|
return t
|
|
return _DEFAULT_TEMPLATE
|
|
|
|
|
|
def save_export_template(template: list, client_id: str = None, user_id: str = None):
|
|
if client_id:
|
|
path = _client_template_path(client_id)
|
|
elif user_id:
|
|
path = _user_template_path(user_id)
|
|
else:
|
|
path = server_config.EXPORT_TEMPLATE_FILE
|
|
with open(path, 'w') as f:
|
|
json.dump(template, f, indent=2)
|
|
|
|
|
|
def detect_csv_template(file_bytes: bytes) -> dict:
|
|
"""Read CSV headers and auto-detect mapping to internal fields."""
|
|
text = file_bytes.decode('utf-8-sig', errors='replace')
|
|
reader = csv.reader(io.StringIO(text))
|
|
headers = [h.strip() for h in (next(reader, [])) if h.strip()]
|
|
|
|
def _match(h: str):
|
|
hl = h.lower().replace('-', ' ').replace('_', ' ')
|
|
candidates = {
|
|
'number': 'Number', 'job no': 'Number', 'job number': 'Number',
|
|
'title': 'Title', 'job title': 'Title', 'name': 'Title', 'campaign': 'Title',
|
|
'status': 'Status',
|
|
'category': 'Category', 'task': 'Category', 'deliverable': 'Category',
|
|
'media': 'Media', 'media type': 'Media', 'channel': 'Media',
|
|
'sub media': 'Sub-media', 'sub-media': 'Sub-media', 'submedia': 'Sub-media',
|
|
'format': 'Format', 'size': 'Format', 'spec': 'Format',
|
|
'supply': 'Supply date', 'supply date': 'Supply date', 'artwork': 'Supply date',
|
|
'live': 'Live date', 'live date': 'Live date', 'go live': 'Live date',
|
|
'end': 'End date', 'end date': 'End date', 'expiry': 'End date',
|
|
'language': 'Language', 'lang': 'Language',
|
|
'country': 'Country', 'market': 'Country', 'region': 'Country',
|
|
'quantity': 'Quantity', 'qty': 'Quantity', 'units': 'Quantity',
|
|
'destination': 'Destination',
|
|
'special': 'Special instructions', 'instructions': 'Special instructions', 'notes': 'Special instructions',
|
|
}
|
|
for key, field in candidates.items():
|
|
if key in hl:
|
|
return field
|
|
return None
|
|
|
|
template = [{'header': h, 'field': _match(h)} for h in headers]
|
|
return {'headers': headers, 'template': template}
|
|
|
|
|
|
def _build_csv(data: list, template: list) -> str:
|
|
headers = [col['header'] for col in template]
|
|
output = io.StringIO()
|
|
writer = csv.DictWriter(output, fieldnames=headers, extrasaction='ignore')
|
|
writer.writeheader()
|
|
for row in data:
|
|
csv_row = {}
|
|
for col in template:
|
|
field = col.get('field')
|
|
header = col['header']
|
|
if not field or field in _CLEAR_BY_DEFAULT:
|
|
csv_row[header] = ''
|
|
elif field == 'Quantity':
|
|
csv_row[header] = '1.00'
|
|
else:
|
|
csv_row[header] = row.get(field, '')
|
|
writer.writerow(csv_row)
|
|
return output.getvalue()
|
|
|
|
|
|
# ── Export endpoint ────────────────────────────────────────────────────────────
|
|
|
|
@export_bp.route('/<sheet_id>/export', methods=['GET'])
|
|
@auth_required
|
|
async def export_csv(sheet_id: str):
|
|
user_id = get_user_id()
|
|
data = load_sheet_data(user_id, sheet_id)
|
|
if data is None:
|
|
return {'error': 'not_found'}, 404
|
|
|
|
client_id = get_sheet_client_id(user_id, sheet_id)
|
|
template = load_export_template(client_id=client_id, user_id=user_id)
|
|
csv_content = _build_csv(data, template)
|
|
|
|
response = await make_response(csv_content)
|
|
response.headers['Content-Type'] = 'text/csv'
|
|
response.headers['Content-Disposition'] = f'attachment; filename="activation_calendar_{sheet_id}.csv"'
|
|
return response
|
|
|
|
|
|
# ── User export template endpoints (any logged-in user) ───────────────────────
|
|
|
|
user_export_bp = Blueprint('user_export', __name__, url_prefix='/api/export')
|
|
|
|
|
|
@user_export_bp.route('/template', methods=['GET'])
|
|
@auth_required
|
|
async def get_user_template():
|
|
user_id = get_user_id()
|
|
path = _user_template_path(user_id)
|
|
has_custom = os.path.exists(path)
|
|
template = load_export_template(user_id=user_id)
|
|
return jsonify({'template': template, 'hasCustom': has_custom, 'fields': INTERNAL_FIELDS})
|
|
|
|
|
|
@user_export_bp.route('/template/detect', methods=['POST'])
|
|
@auth_required
|
|
async def detect_user_template():
|
|
files = await request.files
|
|
file = files.get('file')
|
|
if not file:
|
|
return jsonify({'error': 'no_file'}), 400
|
|
if not (file.filename or '').lower().endswith('.csv'):
|
|
return jsonify({'error': 'Only .csv files accepted'}), 400
|
|
try:
|
|
result = detect_csv_template(file.read())
|
|
result['fields'] = INTERNAL_FIELDS
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 400
|
|
|
|
|
|
@user_export_bp.route('/template', methods=['POST'])
|
|
@auth_required
|
|
async def save_user_template():
|
|
user_id = get_user_id()
|
|
body = await request.get_json() or {}
|
|
template = body.get('template')
|
|
if not template or not isinstance(template, list):
|
|
return jsonify({'error': 'invalid_template'}), 400
|
|
save_export_template(template, user_id=user_id)
|
|
return jsonify({'success': True, 'columns': len(template)})
|
|
|
|
|
|
@user_export_bp.route('/template', methods=['DELETE'])
|
|
@auth_required
|
|
async def delete_user_template():
|
|
user_id = get_user_id()
|
|
path = _user_template_path(user_id)
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
return jsonify({'success': True})
|