import csv import logging import json from io import StringIO from datetime import date import re import unicodedata import requests from celery import shared_task from tenacity import ( retry, stop_after_attempt, wait_exponential, retry_if_exception_type, ) from labs.site_settings import SiteSettings logger = logging.getLogger(__name__) def _safe_filename(name: str, max_length: int = 128) -> str: """Return a filesystem-safe filename. - Normalize unicode to ASCII - Replace spaces with '-' - Allow only [A-Za-z0-9._-] - Collapse duplicate '-' - Trim leading/trailing separators and dots - Ensure non-empty; truncate while keeping extension """ normalized = unicodedata.normalize("NFKD", name) ascii_only = normalized.encode("ascii", "ignore").decode("ascii") replaced_spaces = ascii_only.replace(" ", "-") safe_chars = re.sub(r"[^A-Za-z0-9._-]", "-", replaced_spaces) collapsed = re.sub(r"-{2,}", "-", safe_chars) trimmed = collapsed.strip(" ._-") if not trimmed: trimmed = "file" if len(trimmed) > max_length: base, dot, ext = trimmed.rpartition(".") if dot and len(ext) <= 10: allowed = max_length - len(ext) - 1 base = base[:allowed] if allowed > 0 else base[:max_length] trimmed = f"{base}.{ext}" else: trimmed = trimmed[:max_length] return trimmed @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=15), retry=retry_if_exception_type(requests.exceptions.RequestException), ) def _get_lark_tenant_access_token(app_id: str, app_secret: str) -> str: url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" headers = { "Content-Type": "application/json", } data = { "app_id": app_id, "app_secret": app_secret, } resp = requests.post(url, headers=headers, json=data) resp.raise_for_status() resp_body = resp.json() if resp_body.get("code") != 0: raise requests.exceptions.RequestException(resp_body.get("msg")) return resp_body["tenant_access_token"] @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=15), retry=retry_if_exception_type(requests.exceptions.RequestException), ) def _upload_credit_voucher_file_to_lark( tenant_access_token: str, voucher_codes: list[str], valid_before: date, credits_per_voucher: int, voucher_category: str, customer_name: str, campaign_code: str, voucher_count: int, ) -> str: raw_file_name = ( f"{campaign_code}_{valid_before.strftime('%Y%m%d')}_{voucher_count}.csv" ) file_name = _safe_filename(raw_file_name) url = "https://open.feishu.cn/open-apis/im/v1/files" headers = { "Authorization": f"Bearer {tenant_access_token}", } file_content = StringIO() writer = csv.writer(file_content) writer.writerow([f"Campaign code: {campaign_code}"]) writer.writerow([f"Customer name: {customer_name}"]) writer.writerow([f"Voucher count: {voucher_count}"]) writer.writerow([f"Valid before: {valid_before.strftime('%Y-%m-%d')}"]) writer.writerow([f"Credits per voucher: {credits_per_voucher}"]) writer.writerow([f"Voucher category: {voucher_category}"]) writer.writerow(["Voucher codes:"]) for voucher_code in voucher_codes: writer.writerow([voucher_code]) files = {"file": (file_name, file_content.getvalue(), "text/csv")} data = { "file_type": "xls", "file_name": file_name, } resp = requests.post(url, headers=headers, data=data, files=files) resp.raise_for_status() resp_body = resp.json() if resp_body.get("code") != 0: raise requests.exceptions.RequestException(resp_body.get("msg")) return resp_body["data"]["file_key"] @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=15), retry=retry_if_exception_type(requests.exceptions.RequestException), ) def _send_lark_message( tenant_access_token: str, lark_user_union_id: str, file_key: str, ) -> None: url = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=union_id" headers = { "Authorization": f"Bearer {tenant_access_token}", "Content-Type": "application/json", } data = { "receive_id": lark_user_union_id, "msg_type": "file", "content": json.dumps( { "file_key": file_key, } ), } resp = requests.post(url, headers=headers, json=data) resp.raise_for_status() resp_body = resp.json() if resp_body.get("code") != 0: raise requests.exceptions.RequestException(resp_body.get("msg")) print(resp_body) @shared_task() def send_message_to_lark_user( lark_user_union_id: str, voucher_codes: list[str], valid_before: date, credits_per_voucher: int, voucher_category: str, customer_name: str, campaign_code: str, voucher_count: int, ): lark_app_id = SiteSettings.credit_voucher_notification_lark_app_id lark_app_secret = SiteSettings.credit_voucher_notification_lark_app_secret if not lark_app_id or not lark_app_secret: logger.error("Lark app id or secret is not set") return tenant_access_token = _get_lark_tenant_access_token(lark_app_id, lark_app_secret) file_key = _upload_credit_voucher_file_to_lark( tenant_access_token, voucher_codes, valid_before, credits_per_voucher, voucher_category, customer_name, campaign_code, voucher_count, ) print(file_key) _send_lark_message( tenant_access_token, lark_user_union_id, file_key, )