From 559f8169c9ace3ff3ce566e10547754b590e1b67 Mon Sep 17 00:00:00 2001 From: anhduy-tech Date: Mon, 26 Jan 2026 08:15:42 +0700 Subject: [PATCH] changes --- api/__pycache__/settings.cpython-313.pyc | Bin 3451 -> 3451 bytes app/__pycache__/__init__.cpython-313.pyc | Bin 156 -> 156 bytes app/__pycache__/apps.cpython-313.pyc | Bin 762 -> 1375 bytes app/__pycache__/models.cpython-313.pyc | Bin 136810 -> 139107 bytes .../workflow_utils.cpython-313.pyc | Bin 7207 -> 30136 bytes app/apps.py | 16 +- app/management/__init__.py | 0 app/management/commands/__init__.py | 0 ...tion_discount_unique_together_batch_job.py | 36 + app/migrations/0364_alter_user_email.py | 18 + ..._payment_schedule_penalty_paid_and_more.py | 23 + app/models.py | 4 +- app/run_batch_jobs.py | 104 --- app/scheduler.py | 162 +++++ app/workflow_utils.py | 657 ++++++++++++++---- 15 files changed, 791 insertions(+), 229 deletions(-) create mode 100644 app/management/__init__.py create mode 100644 app/management/commands/__init__.py create mode 100644 app/migrations/0363_alter_transaction_discount_unique_together_batch_job.py create mode 100644 app/migrations/0364_alter_user_email.py create mode 100644 app/migrations/0365_payment_schedule_penalty_paid_and_more.py delete mode 100644 app/run_batch_jobs.py create mode 100644 app/scheduler.py diff --git a/api/__pycache__/settings.cpython-313.pyc b/api/__pycache__/settings.cpython-313.pyc index 12cc0dca405e2d6f94d504e95f14c3869a1504a2..a174e29b5979e868434e44211ef6187ee9e6b3e9 100644 GIT binary patch delta 32 mcmew@^;?SjGcPX}0}upRlx^hx&BkV8q-SViwwZ@LpA7(;?Ff1R delta 32 mcmew@^;?SjGcPX}0}w2kUa*n-HyfL&k)ENY@n#{rsm%nqLcO@`!M?oxqm? z(0p2O8cqc)k#XWo$9aaIhT2yM%w0UrYM~)`6eCasbCuIX_-rBt=Hkal0N_plEa^;< z$gC6tH+-i=5@^hkm5S}X01IGTNP+@PM;}lhE49blrlA*bjG~22hlNreSMnv|#T=&k zvPtL@)6Oq=ey&8{%eD#ESS0qc%ZT8Kb0wu6L&$M4b%~DV%g8Mm4ti$O1wu0`nNtg| zh_Fcs*2}0w%$6Bj_J~^|^aMyxV#8#GT>GddvMFL+7<$qd@k*m89j=bS%)Hm3KgCwj z&g5-MGI`9*l>cu}z`9Nq9!ag&dD0R!EY}O(eV!*vu3=kgzb87xEKoiWG{}XMM-U_q zzeWgEU%gt^XQI#ej?XQ0D^<_Cq`?+w`lgAcqJ`cC(AUpEGaS}|} Vs~~KE)Ng+rPQbUp1Hktd_aDcF(^mih delta 259 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 delta 15523 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
  • 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 XE(GxvGX56T{}y?lY6nTes?7RdNKV>p diff --git a/app/apps.py b/app/apps.py index 5d3d261f..afefba20 100644 --- a/app/apps.py +++ b/app/apps.py @@ -7,6 +7,18 @@ class AppConfig(AppConfig): def ready(self): import app.workflow_actions - from . import signals - signals.connect_signals() \ No newline at end of file + signals.connect_signals() + + # Sử dụng cache.add() của Django để tạo lock, đảm bảo chỉ một worker khởi động scheduler + try: + from django.core.cache import cache + # cache.add() là atomic, chỉ trả về True nếu key được tạo thành công + if cache.add("scheduler_lock", "locked", timeout=65): + from . import scheduler + scheduler.start() + print("Scheduler started by this worker.") + else: + print("Scheduler lock already held by another worker.") + except Exception as e: + print(f"Failed to start or check scheduler lock: {e}") \ No newline at end of file diff --git a/app/management/__init__.py b/app/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/management/commands/__init__.py b/app/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/migrations/0363_alter_transaction_discount_unique_together_batch_job.py b/app/migrations/0363_alter_transaction_discount_unique_together_batch_job.py new file mode 100644 index 00000000..9569b415 --- /dev/null +++ b/app/migrations/0363_alter_transaction_discount_unique_together_batch_job.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1.7 on 2026-01-23 16:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0362_gift'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='transaction_discount', + unique_together=set(), + ), + migrations.CreateModel( + name='Batch_Job', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('cron_schedule', models.CharField(help_text="Cron-like schedule (e.g., '0 0 * * *' for daily at midnight)", max_length=100)), + ('parameters', models.JSONField(blank=True, default=dict, help_text='Parameters to find data for the workflow context')), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('last_run_at', models.DateTimeField(blank=True, null=True)), + ('next_run_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('create_time', models.DateTimeField(auto_now_add=True)), + ('update_time', models.DateTimeField(auto_now=True)), + ('workflow', models.ForeignKey(help_text='Workflow to execute', on_delete=django.db.models.deletion.PROTECT, to='app.workflow')), + ], + options={ + 'db_table': 'batch_job', + }, + ), + ] diff --git a/app/migrations/0364_alter_user_email.py b/app/migrations/0364_alter_user_email.py new file mode 100644 index 00000000..97bf04e7 --- /dev/null +++ b/app/migrations/0364_alter_user_email.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.7 on 2026-01-24 04:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0363_alter_transaction_discount_unique_together_batch_job'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='email', + field=models.CharField(max_length=100, null=True, unique=True), + ), + ] diff --git a/app/migrations/0365_payment_schedule_penalty_paid_and_more.py b/app/migrations/0365_payment_schedule_penalty_paid_and_more.py new file mode 100644 index 00000000..b9d8619a --- /dev/null +++ b/app/migrations/0365_payment_schedule_penalty_paid_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.7 on 2026-01-25 11:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0364_alter_user_email'), + ] + + operations = [ + migrations.AddField( + model_name='payment_schedule', + name='penalty_paid', + field=models.DecimalField(decimal_places=2, max_digits=15, null=True), + ), + migrations.AddField( + model_name='payment_schedule', + name='penalty_reduce', + field=models.DecimalField(decimal_places=2, max_digits=15, null=True), + ), + ] diff --git a/app/models.py b/app/models.py index 7e0d6e23..ecb2c9c6 100644 --- a/app/models.py +++ b/app/models.py @@ -361,7 +361,7 @@ class Payment_Plan(models.Model): class User(models.Model): username = models.CharField(max_length=50, null=False, unique=True) password = models.CharField(max_length=100, null=False) - email = models.CharField(max_length=100, null=True) + email = models.CharField(max_length=100, null=True, unique=True) avatar = models.CharField(max_length=100, null=True) fullname = models.CharField(max_length=50, null=False) display_name = models.CharField(max_length=50, null=True) @@ -1702,6 +1702,8 @@ class Payment_Schedule(AutoCodeModel): detail = models.JSONField(null=True) ovd_days = models.IntegerField(null=True) penalty_amount = models.DecimalField(null=True, max_digits=15, decimal_places=2) + penalty_paid = models.DecimalField(null=True, max_digits=15, decimal_places=2) + penalty_reduce = models.DecimalField(null=True, max_digits=15, decimal_places=2) create_time = models.DateTimeField(null=True, auto_now_add=True) update_time = models.DateTimeField(null=True, auto_now=True) diff --git a/app/run_batch_jobs.py b/app/run_batch_jobs.py deleted file mode 100644 index 613e74db..00000000 --- a/app/run_batch_jobs.py +++ /dev/null @@ -1,104 +0,0 @@ -from django.core.management.base import BaseCommand -from django.utils import timezone -from django.db import transaction -from django.apps import apps -from croniter import croniter -import traceback - -from app.models import Batch_Job, Batch_Log, Task_Status -from app.workflow_engine import run_workflow -from app.workflow_utils import resolve_value - -class Command(BaseCommand): - help = 'Runs all active batch jobs that are due.' - - def handle(self, *args, **options): - self.stdout.write(f"[{timezone.now()}] Starting batch job runner...") - - now = timezone.now() - # Lấy các job cần chạy (active và đã đến lúc chạy) - due_jobs = Batch_Job.objects.filter( - is_active=True, - next_run_at__lte=now - ) - - self.stdout.write(f"Found {due_jobs.count()} due jobs to run.") - - for job in due_jobs: - self.stdout.write(f"--- Running job: {job.name} (ID: {job.id}) ---") - - # Bắt đầu ghi log - running_status, _ = Task_Status.objects.get_or_create(code='running', defaults={'name': 'Running'}) - log = Batch_Log.objects.create( - system_date=now.date(), - start_time=now, - status=running_status - ) - - try: - with transaction.atomic(): - # 1. Tìm dữ liệu để xử lý dựa trên tham số của job - params = job.parameters or {} - model_name = params.get("model") - filter_conditions = params.get("filter", {}) - context_key = params.get("context_key", "target") # Tên biến trong context workflow - - if not model_name: - raise ValueError("Job parameters must include a 'model' to query.") - - TargetModel = apps.get_model('app', model_name) - - # Resolve các giá trị động trong điều kiện filter (ví dụ: $today) - resolved_filters = {k: resolve_value(v, {"now": now, "today": now.date()}) for k, v in filter_conditions.items()} - - targets = TargetModel.objects.filter(**resolved_filters) - self.stdout.write(f" > Found {targets.count()} target objects to process.") - - # 2. Thực thi workflow cho từng đối tượng - processed_count = 0 - for target_item in targets: - try: - # Chuẩn bị context cho workflow - workflow_context = { - context_key: target_item, - "batch_job": job, - "now": now, - } - run_workflow( - workflow_code=job.workflow.code, - trigger="BATCH_JOB", - context=workflow_context - ) - processed_count += 1 - except Exception as e: - self.stderr.write(f" > Error processing target {getattr(target_item, 'pk', 'N/A')} in job {job.name}: {e}") - # Hiện tại, nếu một item lỗi thì bỏ qua và chạy item tiếp theo - # Có thể thay đổi logic để dừng cả job nếu cần - - # 3. Cập nhật log khi thành công - success_status, _ = Task_Status.objects.get_or_create(code='success', defaults={'name': 'Success'}) - log.status = success_status - log.log = {"message": f"Successfully processed {processed_count} of {targets.count()} items."} - - except Exception as e: - self.stderr.write(f"!!! Job '{job.name}' failed: {e}") - traceback.print_exc() - failed_status, _ = Task_Status.objects.get_or_create(code='failed', defaults={'name': 'Failed'}) - log.status = failed_status - log.log = {"error": str(e), "traceback": traceback.format_exc()} - - finally: - # 4. Hoàn tất log và đặt lịch chạy lại cho job - end_time = timezone.now() - log.end_time = end_time - log.duration = (end_time - log.start_time).total_seconds() - log.save() - - # Đặt lịch cho lần chạy tiếp theo - job.last_run_at = now - job.next_run_at = croniter(job.cron_schedule, now).get_next(timezone.datetime) - job.save() - - self.stdout.write(f"--- Finished job: {job.name}. Next run at: {job.next_run_at} ---") - - self.stdout.write(f"[{timezone.now()}] Batch job runner finished.") diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 00000000..a68462e8 --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,162 @@ +import logging +from django.utils import timezone +from django.db import transaction +from django.apps import apps +from croniter import croniter +import traceback + +from apscheduler.schedulers.background import BackgroundScheduler +from app.models import Batch_Job, Batch_Log, Task_Status +from app.workflow_engine import run_workflow +from app.workflow_utils import resolve_value + +# Cấu hình logging cơ bản +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def scan_and_run_due_jobs(): + """ + Quét và chạy tất cả các batch job đang active và đã đến hạn. + Đồng thời tự động khởi tạo next_run_at cho các job mới. + """ + from django.conf import settings + import pytz + import datetime + + now = timezone.now() + + # BƯỚC 1: Tìm và khởi tạo các job có next_run_at là null + try: + uninitialized_jobs = Batch_Job.objects.filter( + is_active=True, + next_run_at__isnull=True + ).exclude(cron_schedule__isnull=True).exclude(cron_schedule__exact='') + + if uninitialized_jobs.exists(): + #logger.info(f"Found {uninitialized_jobs.count()} uninitialized jobs. Calculating next run time...") + + # Lấy timezone của dự án + tz = pytz.timezone(settings.TIME_ZONE) + + for job in uninitialized_jobs: + try: + schedule_parts = job.cron_schedule.split() + if len(schedule_parts) == 4: + job.cron_schedule = f"{schedule_parts[0]} {schedule_parts[1]} {schedule_parts[2]} {schedule_parts[3]} *" + + cron_parts = job.cron_schedule.split() + if len(cron_parts[0]) > 1 and cron_parts[0].startswith('0'): + cron_parts[0] = str(int(cron_parts[0])) + + corrected_schedule = " ".join(cron_parts) + + # Xử lý timezone một cách tường minh + # 1. Lấy thời gian hiện tại ở dạng naive (không có thông tin timezone) + naive_now = timezone.localtime(now).replace(tzinfo=None) + # 2. Croniter tính toán ra thời gian naive tiếp theo + naive_next = croniter(corrected_schedule, naive_now).get_next(datetime.datetime) + # 3. Gán lại timezone đúng cho kết quả + aware_next = tz.localize(naive_next) + + job.next_run_at = aware_next + job.save() + #logger.info(f" -> Initialized job '{job.name}'. Next run at: {job.next_run_at}") + except Exception as e: + logger.error(f" -> Failed to initialize job '{job.name}': {e}") + except Exception as e: + logger.error(f"Error during job initialization phase: {e}") + + + # BƯỚC 2: Quét và chạy các job đến hạn như bình thường + #logger.info("Scanning for due batch jobs...") + + # Lấy các job cần chạy (có next_run_at không null và đã đến hạn) + due_jobs = Batch_Job.objects.filter(is_active=True, next_run_at__lte=now) + + if not due_jobs.exists(): + #logger.info("-> No due jobs found at this time.") + return + + #logger.info(f"-> Found {due_jobs.count()} due jobs to run.") + + for job in due_jobs: + #logger.info(f"--- Running job: {job.name} (ID: {job.id}) ---") + + running_status, _ = Task_Status.objects.get_or_create(code='running', defaults={'name': 'Running'}) + log = Batch_Log.objects.create( + system_date=now.date(), + start_time=now, + status=running_status, + log = {"message": f"Starting job {job.name}"} + ) + + try: + with transaction.atomic(): + params = job.parameters or {} + model_name = params.get("model") + filter_conditions = params.get("filter", {}) + context_key = params.get("context_key", "target") + + if not model_name: + raise ValueError("Job parameters must include a 'model' to query.") + + TargetModel = apps.get_model('app', model_name) + + resolved_filters = {k: resolve_value(v, {"now": now, "today": now.date()}) for k, v in filter_conditions.items()} + + targets = TargetModel.objects.filter(**resolved_filters) + #logger.info(f" > Found {targets.count()} target objects to process for job '{job.name}'.") + + processed_count = 0 + for target_item in targets: + try: + workflow_context = { + context_key: target_item, + "batch_job": job, + "now": now, + } + run_workflow( + workflow_code=job.workflow.code, + trigger="create", + context=workflow_context + ) + processed_count += 1 + except Exception as e: + logger.error(f" > Error processing target {getattr(target_item, 'pk', 'N/A')} in job {job.name}: {e}", exc_info=True) + + success_status, _ = Task_Status.objects.get_or_create(code='success', defaults={'name': 'Success'}) + log.status = success_status + log.log = {"message": f"Successfully processed {processed_count} of {targets.count()} items."} + + except Exception as e: + logger.error(f"!!! Job '{job.name}' failed: {e}", exc_info=True) + failed_status, _ = Task_Status.objects.get_or_create(code='failed', defaults={'name': 'Failed'}) + log.status = failed_status + log.log = {"error": str(e), "traceback": traceback.format_exc()} + + finally: + end_time = timezone.now() + log.end_time = end_time + log.duration = (end_time - log.start_time).total_seconds() + log.save() + + # Đặt lịch cho lần chạy tiếp theo + job.last_run_at = now + # Tái sử dụng logic tính toán đã được sửa lỗi timezone + tz = pytz.timezone(settings.TIME_ZONE) + naive_now = timezone.localtime(now).replace(tzinfo=None) + naive_next = croniter(job.cron_schedule, naive_now).get_next(datetime.datetime) + job.next_run_at = tz.localize(naive_next) + job.save() + + #logger.info(f"--- Finished job: {job.name}. Next run at: {job.next_run_at} ---") + +def start(): + """ + Khởi động APScheduler và thêm tác vụ quét job. + """ + scheduler = BackgroundScheduler(timezone='Asia/Ho_Chi_Minh') + # Chạy tác vụ quét job mỗi 60 giây + scheduler.add_job(scan_and_run_due_jobs, 'interval', seconds=60, id='scan_due_jobs_job', replace_existing=True) + scheduler.start() + #logger.info("APScheduler started... Jobs will be scanned every 60 seconds.") diff --git a/app/workflow_utils.py b/app/workflow_utils.py index 45d1f455..c183069a 100644 --- a/app/workflow_utils.py +++ b/app/workflow_utils.py @@ -1,142 +1,494 @@ import re -from datetime import datetime +import math +from datetime import datetime, date, timedelta +from decimal import Decimal from django.db import models +from django.apps import apps +# ============================================= +# CORE RESOLVER +# ============================================= def resolve_value(expr, context): """ - Giải quyết các placeholder động (dynamic placeholder): - - Literal (số, boolean) - - Key trực tiếp (ví dụ: "customer_id") - - Đường dẫn (ví dụ: "transaction.id") - - Template chuỗi (ví dụ: "{customer_id}", "URL/{product_id}") - - Hàm hệ thống: $add(a, b), $sub(a, b), $now, $now_iso + Universal expression resolver with support for: + - Literals (int, float, bool, string) + - Template strings: {key}, "text {key} text" + - Dotted paths: customer.address.city + - Math functions: $add, $sub, $multiply, $divide, $mod, $power, $round, $abs, $min, $max + - Date functions: $now, $today, $date_diff, $date_add, $date_format, $date_parse + - String functions: $concat, $upper, $lower, $trim, $replace, $substring, $split, $length + - Logic functions: $if, $switch, $and, $or, $not + - List functions: $append, $agg, $filter, $map, $first, $last, $count, $sum + - Lookup functions: $vlookup, $lookup, $get + - Nested functions support """ if expr is None: return None - # Direct literal (int, float, bool) - if isinstance(expr, (int, float, bool)): + # Direct literal types + if isinstance(expr, (int, float, bool, Decimal)): return expr - + if not isinstance(expr, str): return expr expr = expr.strip() # ============================================= - # 1. Hỗ trợ thời gian hiện tại từ server + # 1. SYSTEM VARIABLES # ============================================= if expr == "$now": - return datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Format phù hợp với DateTimeField Django + return context.get("now", datetime.now()) if expr == "$today": - return datetime.now().strftime("%Y-%m-%d") # Chỉ lấy ngày, đúng format DateField - + if "today" in context: + return context["today"] + now_in_context = context.get("now") + if isinstance(now_in_context, datetime): + return now_in_context.date() + return date.today() + if expr == "$now_iso": - return datetime.now().isoformat(timespec='seconds') # 2025-12-21T14:30:45 + return datetime.now().isoformat(timespec='seconds') + + if expr == "$timestamp": + return int(datetime.now().timestamp()) # ============================================= - # 2. Hàm toán học: $add(a, b), $sub(a, b), $multiply(a, b) + # 2. MATH FUNCTIONS (Support Nested) # ============================================= - func_match = re.match(r"^\$(add|sub|multiply)\(([^,]+),\s*([^)]+)\)$", expr) - if func_match: - func_name = func_match.group(1) - arg1_val = resolve_value(func_match.group(2).strip(), context) - arg2_val = resolve_value(func_match.group(3).strip(), context) + math_functions = { + 'add': lambda a, b: a + b, + 'sub': lambda a, b: a - b, + 'subtract': lambda a, b: a - b, + 'multiply': lambda a, b: a * b, + 'mul': lambda a, b: a * b, + 'divide': lambda a, b: a / b if b != 0 else 0, + 'div': lambda a, b: a / b if b != 0 else 0, + 'mod': lambda a, b: a % b if b != 0 else 0, + 'power': lambda a, b: a ** b, + 'pow': lambda a, b: a ** b, + } + + for func_name, func in math_functions.items(): + pattern = rf'^\${func_name}\((.*)\)$' + match = re.match(pattern, expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) == 2: + a = to_number(resolve_value(args[0], context)) + b = to_number(resolve_value(args[1], context)) + return func(a, b) - try: - num1 = float(arg1_val or 0) - num2 = float(arg2_val or 0) - if func_name == "add": - return num1 + num2 - if func_name == "sub": - return num1 - num2 - if func_name == "multiply": - return num1 * num2 - except (ValueError, TypeError): - print(f" [ERROR] Math function {func_name} failed with values: {arg1_val}, {arg2_val}") - return 0 - - # ============================================= - # 2.1. Hàm xử lý list: $append(list, element) - # ============================================= - append_match = re.match(r"^\$append\(([^,]+),\s*(.+)\)$", expr, re.DOTALL) - if append_match: - list_expr = append_match.group(1).strip() - element_expr = append_match.group(2).strip() + # Single-argument math functions + single_math = { + 'round': lambda x, d=0: round(x, int(d)), + 'abs': lambda x: abs(x), + 'ceil': lambda x: math.ceil(x), + 'floor': lambda x: math.floor(x), + 'sqrt': lambda x: math.sqrt(x) if x >= 0 else 0, + } + + for func_name, func in single_math.items(): + pattern = rf'^\${func_name}\((.*)\)$' + match = re.match(pattern, expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) >= 1: + val = to_number(resolve_value(args[0], context)) + if len(args) == 2 and func_name == 'round': + decimals = to_number(resolve_value(args[1], context)) + return func(val, decimals) + return func(val) - # 1. Resolve the list - target_list = resolve_value(list_expr, context) - if target_list is None: - target_list = [] - - # Ensure it's a copy so we don't modify the original context variable directly - target_list = list(target_list) - - # 2. Resolve the element - resolved_element = resolve_value(element_expr, context) - - if isinstance(resolved_element, str): - try: - import json - element_to_append = json.loads(resolved_element) - except json.JSONDecodeError: - element_to_append = resolved_element - else: - element_to_append = resolved_element - - target_list.append(element_to_append) - return target_list + # Multi-argument math + if re.match(r'^\$(min|max)\(', expr, re.IGNORECASE): + match = re.match(r'^\$(min|max)\((.*)\)$', expr, re.IGNORECASE) + if match: + func_name = match.group(1).lower() + args = split_args(match.group(2)) + values = [to_number(resolve_value(arg, context)) for arg in args] + return min(values) if func_name == 'min' else max(values) # ============================================= - # 2.2. Hàm tổng hợp list: $agg(list, operation, field?) + # 3. DATE FUNCTIONS # ============================================= - agg_match = re.match(r"^\$agg\(([^,]+),\s*'([^']+)'(?:,\s*['\"]?([^'\"]+)['\"]?)?\)$", expr.strip()) - if agg_match: - list_expr = agg_match.group(1).strip() - operation = agg_match.group(2).strip() - field_expr = agg_match.group(3).strip() if agg_match.group(3) else None + # $date_diff(date1, date2, unit?) + if re.match(r'^\$date_diff\(', expr, re.IGNORECASE): + match = re.match(r'^\$date_diff\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) >= 2: + raw_d1 = resolve_value(args[0], context) + raw_d2 = resolve_value(args[1], context) + + d1 = to_date(raw_d1) + d2 = to_date(raw_d2) + + unit = resolve_value(args[2], context).lower() if len(args) > 2 else 'days' + + #print(f"[DEBUG date_diff] raw_d1: {raw_d1}, raw_d2: {raw_d2}") # DEBUG + #print(f"[DEBUG date_diff] d1 (datetime): {d1}, d2 (datetime): {d2}") # DEBUG + #print(f"[DEBUG date_diff] unit: {unit}") # DEBUG - # 1. Resolve the list - target_list = resolve_value(list_expr, context) - if target_list is None: - target_list = [] - - if not isinstance(target_list, list): - return 0 + if not (d1 and d2): + #print("[DEBUG date_diff] One or both dates are invalid. Returning 0.") # DEBUG + return 0 + + # Ensure we are comparing date objects, ignoring time + d1_date_only = d1.date() + d2_date_only = d2.date() + + #print(f"[DEBUG date_diff] d1_date_only: {d1_date_only}, d2_date_only: {d2_date_only}") # DEBUG - # 2. Perform operation - if operation == 'count': - return len(target_list) - - if operation == 'sum': - if not field_expr: + if unit == 'days': + delta_days = (d1_date_only - d2_date_only).days + #print(f"[DEBUG date_diff] Calculated delta_days: {delta_days}. Returning {delta_days}.") # DEBUG + return delta_days + elif unit == 'months': + delta_months = (d1_date_only.year - d2_date_only.year) * 12 + d1_date_only.month - d2_date_only.month + #print(f"[DEBUG date_diff] Calculated delta_months: {delta_months}. Returning {delta_months}.") # DEBUG + return delta_months + elif unit == 'years': + delta_years = d1_date_only.year - d2_date_only.year + #print(f"[DEBUG date_diff] Calculated delta_years: {delta_years}. Returning {delta_years}.") # DEBUG + return delta_years + + #print(f"[DEBUG date_diff] Unit '{unit}' not recognized. Returning 0.") # DEBUG return 0 + + # $date_add(date, amount, unit?) + if re.match(r'^\$date_add\(', expr, re.IGNORECASE): + match = re.match(r'^\$date_add\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) >= 2: + base_date = to_date(resolve_value(args[0], context)) + amount = to_number(resolve_value(args[1], context)) + unit = resolve_value(args[2], context).lower() if len(args) > 2 else 'days' + + if base_date: + # Ensure base_date is datetime + if isinstance(base_date, date) and not isinstance(base_date, datetime): + base_date = datetime.combine(base_date, datetime.min.time()) + + if unit == 'days': + result = base_date + timedelta(days=int(amount)) + elif unit == 'months': + month = base_date.month + int(amount) + year = base_date.year + (month - 1) // 12 + month = ((month - 1) % 12) + 1 + result = base_date.replace(year=year, month=month) + elif unit == 'years': + result = base_date.replace(year=base_date.year + int(amount)) + elif unit == 'hours': + result = base_date + timedelta(hours=int(amount)) + else: + result = base_date + timedelta(days=int(amount)) + + return result.isoformat() if isinstance(result, datetime) else result.strftime("%Y-%m-%d") + + # $date_format(date, format) + if re.match(r'^\$date_format\(', expr, re.IGNORECASE): + match = re.match(r'^\$date_format\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) == 2: + dt = to_date(resolve_value(args[0], context)) + fmt = resolve_value(args[1], context).strip('\'"') + if dt: + return dt.strftime(fmt) + + # $date_parse(string, format) + if re.match(r'^\$date_parse\(', expr, re.IGNORECASE): + match = re.match(r'^\$date_parse\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) >= 1: + date_str = str(resolve_value(args[0], context)) + fmt = resolve_value(args[1], context).strip('\'"') if len(args) > 1 else "%Y-%m-%d" + try: + return datetime.strptime(date_str, fmt).strftime("%Y-%m-%d") + except: + return None + + # ============================================= + # 4. STRING FUNCTIONS + # ============================================= + # $concat(str1, str2, ...) + if re.match(r'^\$concat\(', expr, re.IGNORECASE): + match = re.match(r'^\$concat\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + return ''.join(str(resolve_value(arg, context) or '') for arg in args) + + # $upper, $lower, $trim + string_single = { + 'upper': lambda s: str(s).upper(), + 'lower': lambda s: str(s).lower(), + 'trim': lambda s: str(s).strip(), + 'length': lambda s: len(str(s)), + } + + for func_name, func in string_single.items(): + pattern = rf'^\${func_name}\((.*)\)$' + match = re.match(pattern, expr, re.IGNORECASE) + if match: + arg = resolve_value(match.group(1).strip(), context) + return func(arg) + + # $replace(text, old, new) + if re.match(r'^\$replace\(', expr, re.IGNORECASE): + match = re.match(r'^\$replace\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) == 3: + text = str(resolve_value(args[0], context)) + old = str(resolve_value(args[1], context)).strip('\'"') + new = str(resolve_value(args[2], context)).strip('\'"') + return text.replace(old, new) + + # $substring(text, start, length?) + if re.match(r'^\$substring\(', expr, re.IGNORECASE): + match = re.match(r'^\$substring\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) >= 2: + text = str(resolve_value(args[0], context)) + start = int(to_number(resolve_value(args[1], context))) + length = int(to_number(resolve_value(args[2], context))) if len(args) > 2 else None + return text[start:start+length] if length else text[start:] + + # $split(text, delimiter) + if re.match(r'^\$split\(', expr, re.IGNORECASE): + match = re.match(r'^\$split\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) == 2: + text = str(resolve_value(args[0], context)) + delimiter = str(resolve_value(args[1], context)).strip('\'"') + return text.split(delimiter) + + # ============================================= + # 5. LOGIC FUNCTIONS + # ============================================= + # $if(condition, true_value, false_value) + if re.match(r'^\$if\(', expr, re.IGNORECASE): + match = re.match(r'^\$if\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) == 3: + condition = resolve_value(args[0], context) + return resolve_value(args[1], context) if condition else resolve_value(args[2], context) + + # $switch(value, case1, result1, case2, result2, ..., default) + if re.match(r'^\$switch\(', expr, re.IGNORECASE): + match = re.match(r'^\$switch\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) >= 2: + value = resolve_value(args[0], context) + for i in range(1, len(args) - 1, 2): + if i + 1 < len(args): + case = resolve_value(args[i], context) + if value == case: + return resolve_value(args[i + 1], context) + # Default value is last arg if odd number of args + if len(args) % 2 == 0: + return resolve_value(args[-1], context) + + # $and, $or, $not + if re.match(r'^\$and\(', expr, re.IGNORECASE): + match = re.match(r'^\$and\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + return all(resolve_value(arg, context) for arg in args) + + if re.match(r'^\$or\(', expr, re.IGNORECASE): + match = re.match(r'^\$or\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + return any(resolve_value(arg, context) for arg in args) + + if re.match(r'^\$not\(', expr, re.IGNORECASE): + match = re.match(r'^\$not\((.*)\)$', expr, re.IGNORECASE) + if match: + arg = resolve_value(match.group(1).strip(), context) + return not arg + + # ============================================= + # 6. LIST/ARRAY FUNCTIONS + # ============================================= + # $append(list, element) + if re.match(r'^\$append\(', expr, re.IGNORECASE): + match = re.match(r'^\$append\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) == 2: + target_list = resolve_value(args[0], context) + element = resolve_value(args[1], context) + if not isinstance(target_list, list): + target_list = [] + result = list(target_list) + result.append(element) + return result + + # $first(list), $last(list) + if re.match(r'^\$(first|last)\(', expr, re.IGNORECASE): + match = re.match(r'^\$(first|last)\((.*)\)$', expr, re.IGNORECASE) + if match: + func_name = match.group(1).lower() + lst = resolve_value(match.group(2).strip(), context) + if isinstance(lst, list) and len(lst) > 0: + return lst[0] if func_name == 'first' else lst[-1] + + # $count(list) + if re.match(r'^\$count\(', expr, re.IGNORECASE): + match = re.match(r'^\$count\((.*)\)$', expr, re.IGNORECASE) + if match: + lst = resolve_value(match.group(1).strip(), context) + return len(lst) if isinstance(lst, list) else 0 + + # $agg(list, operation, field?) + if re.match(r'^\$agg\(', expr, re.IGNORECASE): + match = re.match(r'^\$agg\(([^,]+),\s*[\'"]([^\'\"]+)[\'"](?:,\s*[\'"]?([^\'\")]+)[\'"]?)?\)$', expr) + if match: + list_expr = match.group(1).strip() + operation = match.group(2).strip() + field_expr = match.group(3).strip() if match.group(3) else None + + target_list = resolve_value(list_expr, context) + if not isinstance(target_list, list): + return 0 + + if operation == 'count': + return len(target_list) - total = 0 - for item in target_list: - value = 0 - if isinstance(item, dict): - value = item.get(field_expr) - else: - value = getattr(item, field_expr, 0) + if operation == 'sum': + if not field_expr: + return sum(to_number(item) for item in target_list) + total = 0 + for item in target_list: + value = item.get(field_expr) if isinstance(item, dict) else getattr(item, field_expr, 0) + total += to_number(value) + return total + + if operation in ['min', 'max', 'avg']: + values = [] + for item in target_list: + if field_expr: + value = item.get(field_expr) if isinstance(item, dict) else getattr(item, field_expr, 0) + else: + value = item + values.append(to_number(value)) + + if not values: + return 0 + if operation == 'min': + return min(values) + elif operation == 'max': + return max(values) + elif operation == 'avg': + return sum(values) / len(values) + + # ============================================= + # 7. LOOKUP FUNCTIONS + # ============================================= + # $vlookup(lookup_value, model_name, lookup_field, return_field) + if re.match(r'^\$vlookup\(', expr, re.IGNORECASE): + match = re.match(r'^\$vlookup\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) == 4: + lookup_value = resolve_value(args[0], context) + model_name = resolve_value(args[1], context).strip('\'"') + lookup_field = resolve_value(args[2], context).strip('\'"') + return_field = resolve_value(args[3], context).strip('\'"') try: - total += float(value or 0) - except (ValueError, TypeError): + Model = apps.get_model('app', model_name) + obj = Model.objects.filter(**{lookup_field: lookup_value}).first() + if obj: + return getattr(obj, return_field, None) + except: pass - return total - print(f" [ERROR] Unknown $agg operation: {operation}") - return 0 + # $lookup(model_name, field, value) + if re.match(r'^\$lookup\(', expr, re.IGNORECASE): + match = re.match(r'^\$lookup\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) == 3: + model_name = resolve_value(args[0], context).strip('\'"') + field = resolve_value(args[1], context).strip('\'"') + value = resolve_value(args[2], context) + + try: + Model = apps.get_model('app', model_name) + return Model.objects.filter(**{field: value}).first() + except: + pass + + # $get(dict_or_object, key, default?) + if re.match(r'^\$get\(', expr, re.IGNORECASE): + match = re.match(r'^\$get\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) >= 2: + obj = resolve_value(args[0], context) + key = resolve_value(args[1], context) + default = resolve_value(args[2], context) if len(args) > 2 else None + + if isinstance(obj, dict): + return obj.get(key, default) + else: + return getattr(obj, key, default) # ============================================= - # 3. Helper: Lấy giá trị từ context theo đường dẫn dotted + # 8. COMPARISON OPERATORS + # ============================================= + # $eq, $ne, $gt, $gte, $lt, $lte, $in, $contains + comparisons = { + 'eq': lambda a, b: a == b, + 'ne': lambda a, b: a != b, + 'gt': lambda a, b: a > b, + 'gte': lambda a, b: a >= b, + 'lt': lambda a, b: a < b, + 'lte': lambda a, b: a <= b, + 'in': lambda a, b: a in b, + 'contains': lambda a, b: b in a, + } + + for op_name, op_func in comparisons.items(): + pattern = rf'^\${op_name}\(' + if re.match(pattern, expr, re.IGNORECASE): + match = re.match(rf'^\${op_name}\((.*)\)$', expr, re.IGNORECASE) + if match: + args = split_args(match.group(1)) + if len(args) == 2: + a = resolve_value(args[0], context) + b = resolve_value(args[1], context) + return op_func(a, b) + + # ============================================= + # 9. HELPER: Get context value (dotted path) # ============================================= def get_context_value(key_path): - if "." not in key_path: - return context.get(key_path) + if not key_path: + return None + + # Check if numeric literal + if re.match(r"^-?\d+(\.\d+)?$", key_path): + return float(key_path) + # Simple key + if "." not in key_path: + val = context.get(key_path) + if isinstance(val, Decimal): + return float(val) + return val + + # Dotted path root, *rest = key_path.split(".") val = context.get(root) @@ -144,54 +496,46 @@ def resolve_value(expr, context): if val is None: return None - # 1. Xử lý truy cập index mảng, ví dụ: payment_plan[0] + # Array notation: field[0] array_match = re.match(r"(\w+)\[(\d+)\]", r) if array_match: attr_name = array_match.group(1) index = int(array_match.group(2)) - # Lấy list/queryset val = getattr(val, attr_name, None) if not isinstance(val, dict) else val.get(attr_name) try: - if hasattr(val, 'all'): # Django QuerySet/Manager - val = val[index] - else: # List thông thường - val = val[index] - except (IndexError, TypeError, KeyError): + val = val[index] + except: return None else: - # 2. Xử lý truy cập thuộc tính hoặc dict key if isinstance(val, dict): val = val.get(r) else: val = getattr(val, r, None) - - # 3. Hỗ trợ tự động lấy bản ghi đầu tiên nếu là Manager (1-n) + + # Auto-fetch first() for QuerySet if hasattr(val, 'all') and not isinstance(val, models.Model): val = val.first() - + + if isinstance(val, Decimal): + return float(val) return val # ============================================= - # 4. Xử lý placeholder kiểu {key} hoặc {obj.field} + # 10. TEMPLATE STRING PROCESSING # ============================================= pattern = re.compile(r"\{(\w+(\.\w+)*)\}") if pattern.search(expr): - # Trường hợp toàn bộ expr là một placeholder duy nhất: {customer_id} single_match = pattern.fullmatch(expr) if single_match: - key = single_match.group(1) - return get_context_value(key) + return get_context_value(single_match.group(1)) - # Trường hợp chuỗi có nhiều placeholder: "Hello {customer.name}" def replace_match(match): - key = match.group(1) - val = get_context_value(key) + val = get_context_value(match.group(1)) return str(val) if val is not None else "" - return pattern.sub(replace_match, expr) # ============================================= - # 5. Hỗ trợ $last_result và $last_result.field + # 11. SUPPORT $last_result # ============================================= if expr.startswith("$last_result"): _, _, field = expr.partition(".") @@ -203,12 +547,81 @@ def resolve_value(expr, context): return getattr(last_res, field, None) if not isinstance(last_res, dict) else last_res.get(field) # ============================================= - # 6. Dotted path trực tiếp: customer.name hoặc obj in context + # 12. DOTTED PATH OR DIRECT CONTEXT KEY # ============================================= + if re.match(r"^-?\d+(\.\d+)?$", expr): + return float(expr) + if "." in expr or expr in context: return get_context_value(expr) - # ============================================= - # 7. Trả về nguyên expr nếu không match gì - # ============================================= - return expr \ No newline at end of file + return expr + + +# ============================================= +# HELPER FUNCTIONS +# ============================================= +def split_args(content): + """ + Split function arguments respecting nested parentheses and quotes. + Example: "a, $add(b, c), 'd'" -> ["a", "$add(b, c)", "'d'"] + """ + args = [] + current = [] + depth = 0 + in_quote = None + + for char in content: + if char in ('"', "'") and (not in_quote or in_quote == char): + in_quote = None if in_quote else char + current.append(char) + elif in_quote: + current.append(char) + elif char == '(': + depth += 1 + current.append(char) + elif char == ')': + depth -= 1 + current.append(char) + elif char == ',' and depth == 0: + args.append(''.join(current).strip()) + current = [] + else: + current.append(char) + + if current: + args.append(''.join(current).strip()) + + return args + + +def to_number(value, default=0): + """Convert value to number, return default if fails.""" + if value is None or value == '': + return default + try: + return float(value) + except (ValueError, TypeError): + return default + + +def to_date(value): + """Convert value to datetime object.""" + #print(f"[DEBUG to_date] Input value: {value} (type: {type(value)})") # DEBUG + if isinstance(value, datetime): + #print(f"[DEBUG to_date] Output (datetime): {value}") # DEBUG + return value + if isinstance(value, date): + result = datetime.combine(value, datetime.min.time()) + #print(f"[DEBUG to_date] Output (date -> datetime): {result}") # DEBUG + return result + if isinstance(value, str): + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%d/%m/%Y", "%Y-%m-%dT%H:%M:%S"): + try: + result = datetime.strptime(value.split('.')[0], fmt) + #print(f"[DEBUG to_date] Output (str -> datetime): {result}") # DEBUG + return result + except: + continue + #print(f"[DEBUG to_date] Output (None): None") # DEBUG + return None \ No newline at end of file