ac-tool/backend/server/api/export.py
Vadym Samoilenko 8da149b84e Migrate storage from JSON files to PostgreSQL (asyncpg)
- Add asyncpg connection pool (db/pool.py) with JSONB codec registration
- Add schema.sql with users, clients, dropdown_categories, export_templates, sheets tables
- Add migrate_json.py one-time migration script for existing JSON data
- Rewrite user_store, sheets/manager, api/clients, api/dropdowns, api/export as async DB-backed
- Update all callers (auth, sheets, admin, ai_command, export) to await async functions
- Add postgres:16-alpine service to docker-compose with named volume and health check
- App container depends_on postgres; DATABASE_URL injected via env
- Schema applied automatically on startup; global categories seeded if DB is empty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:51:37 +00:00

245 lines
9.1 KiB
Python

"""
CSV export — Activation Calendar format.
Supports custom export templates: client > user > global > built-in default.
Template data stored in PostgreSQL export_templates table.
"""
import csv
import io
import logging
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 ..db.pool import get_pool
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'},
]
async def load_export_template(client_id: str = None, user_id: str = None) -> list:
"""
Priority: client template → user template → global template → built-in default.
"""
pool = get_pool()
async with pool.acquire() as conn:
if client_id:
row = await conn.fetchrow(
'SELECT columns FROM export_templates WHERE scope = $1', f'client:{client_id}'
)
if row:
return row['columns']
if user_id:
row = await conn.fetchrow(
'SELECT columns FROM export_templates WHERE scope = $1', f'user:{user_id}'
)
if row:
return row['columns']
row = await conn.fetchrow(
"SELECT columns FROM export_templates WHERE scope = 'global'"
)
if row:
return row['columns']
return _DEFAULT_TEMPLATE
async def save_export_template(template: list, client_id: str = None, user_id: str = None):
if client_id:
scope = f'client:{client_id}'
elif user_id:
scope = f'user:{user_id}'
else:
scope = 'global'
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute('''
INSERT INTO export_templates (scope, columns)
VALUES ($1, $2)
ON CONFLICT (scope) DO UPDATE SET columns = $2, updated_at = NOW()
''', scope, template)
async def delete_export_template(client_id: str = None, user_id: str = None):
if client_id:
scope = f'client:{client_id}'
elif user_id:
scope = f'user:{user_id}'
else:
scope = 'global'
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute('DELETE FROM export_templates WHERE scope = $1', scope)
async def has_export_template(client_id: str = None, user_id: str = None) -> bool:
if client_id:
scope = f'client:{client_id}'
elif user_id:
scope = f'user:{user_id}'
else:
scope = 'global'
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow('SELECT 1 FROM export_templates WHERE scope = $1', scope)
return row is not None
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 = await load_sheet_data(user_id, sheet_id)
if data is None:
return {'error': 'not_found'}, 404
client_id = await get_sheet_client_id(user_id, sheet_id)
template = await 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()
has_custom = await has_export_template(user_id=user_id)
template = await 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
await 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()
await delete_export_template(user_id=user_id)
return jsonify({'success': True})