ac-tool/backend/server/api/export.py
Vadym Samoilenko 6c93915768 Add custom CSV export template (per-client, per-user, global)
- 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>
2026-03-23 19:29:22 +00:00

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})