This commit is contained in:
anhduy-tech
2026-01-26 08:15:42 +07:00
parent 2ac88177d8
commit 559f8169c9
15 changed files with 791 additions and 229 deletions

Binary file not shown.

View File

@@ -7,6 +7,18 @@ class AppConfig(AppConfig):
def ready(self): def ready(self):
import app.workflow_actions import app.workflow_actions
from . import signals from . import signals
signals.connect_signals() 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}")

View File

View File

View File

@@ -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',
},
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -361,7 +361,7 @@ class Payment_Plan(models.Model):
class User(models.Model): class User(models.Model):
username = models.CharField(max_length=50, null=False, unique=True) username = models.CharField(max_length=50, null=False, unique=True)
password = models.CharField(max_length=100, null=False) 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) avatar = models.CharField(max_length=100, null=True)
fullname = models.CharField(max_length=50, null=False) fullname = models.CharField(max_length=50, null=False)
display_name = models.CharField(max_length=50, null=True) display_name = models.CharField(max_length=50, null=True)
@@ -1702,6 +1702,8 @@ class Payment_Schedule(AutoCodeModel):
detail = models.JSONField(null=True) detail = models.JSONField(null=True)
ovd_days = models.IntegerField(null=True) ovd_days = models.IntegerField(null=True)
penalty_amount = models.DecimalField(null=True, max_digits=15, decimal_places=2) 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) create_time = models.DateTimeField(null=True, auto_now_add=True)
update_time = models.DateTimeField(null=True, auto_now=True) update_time = models.DateTimeField(null=True, auto_now=True)

View File

@@ -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.")

162
app/scheduler.py Normal file
View File

@@ -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.")

View File

@@ -1,21 +1,32 @@
import re 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.db import models
from django.apps import apps
# =============================================
# CORE RESOLVER
# =============================================
def resolve_value(expr, context): def resolve_value(expr, context):
""" """
Giải quyết các placeholder động (dynamic placeholder): Universal expression resolver with support for:
- Literal (số, boolean) - Literals (int, float, bool, string)
- Key trực tiếp (ví dụ: "customer_id") - Template strings: {key}, "text {key} text"
- Đường dẫn (ví dụ: "transaction.id") - Dotted paths: customer.address.city
- Template chuỗi (ví dụ: "{customer_id}", "URL/{product_id}") - Math functions: $add, $sub, $multiply, $divide, $mod, $power, $round, $abs, $min, $max
- Hàm hệ thống: $add(a, b), $sub(a, b), $now, $now_iso - 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: if expr is None:
return None return None
# Direct literal (int, float, bool) # Direct literal types
if isinstance(expr, (int, float, bool)): if isinstance(expr, (int, float, bool, Decimal)):
return expr return expr
if not isinstance(expr, str): if not isinstance(expr, str):
@@ -24,119 +35,460 @@ def resolve_value(expr, context):
expr = expr.strip() expr = expr.strip()
# ============================================= # =============================================
# 1. Hỗ trợ thời gian hiện tại từ server # 1. SYSTEM VARIABLES
# ============================================= # =============================================
if expr == "$now": 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": 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": 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) math_functions = {
if func_match: 'add': lambda a, b: a + b,
func_name = func_match.group(1) 'sub': lambda a, b: a - b,
arg1_val = resolve_value(func_match.group(2).strip(), context) 'subtract': lambda a, b: a - b,
arg2_val = resolve_value(func_match.group(3).strip(), context) '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,
}
try: for func_name, func in math_functions.items():
num1 = float(arg1_val or 0) pattern = rf'^\${func_name}\((.*)\)$'
num2 = float(arg2_val or 0) match = re.match(pattern, expr, re.IGNORECASE)
if func_name == "add": if match:
return num1 + num2 args = split_args(match.group(1))
if func_name == "sub": if len(args) == 2:
return num1 - num2 a = to_number(resolve_value(args[0], context))
if func_name == "multiply": b = to_number(resolve_value(args[1], context))
return num1 * num2 return func(a, b)
except (ValueError, TypeError):
print(f" [ERROR] Math function {func_name} failed with values: {arg1_val}, {arg2_val}") # 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)
# 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)
# =============================================
# 3. DATE FUNCTIONS
# =============================================
# $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
if not (d1 and d2):
#print("[DEBUG date_diff] One or both dates are invalid. Returning 0.") # DEBUG
return 0 return 0
# ============================================= # Ensure we are comparing date objects, ignoring time
# 2.1. Hàm xử lý list: $append(list, element) d1_date_only = d1.date()
# ============================================= d2_date_only = d2.date()
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()
# 1. Resolve the list #print(f"[DEBUG date_diff] d1_date_only: {d1_date_only}, d2_date_only: {d2_date_only}") # DEBUG
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 if unit == 'days':
target_list = list(target_list) 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
# 2. Resolve the element #print(f"[DEBUG date_diff] Unit '{unit}' not recognized. Returning 0.") # DEBUG
resolved_element = resolve_value(element_expr, context) return 0
if isinstance(resolved_element, str): # $date_add(date, amount, unit?)
try: if re.match(r'^\$date_add\(', expr, re.IGNORECASE):
import json match = re.match(r'^\$date_add\((.*)\)$', expr, re.IGNORECASE)
element_to_append = json.loads(resolved_element) if match:
except json.JSONDecodeError: args = split_args(match.group(1))
element_to_append = resolved_element 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: else:
element_to_append = resolved_element result = base_date + timedelta(days=int(amount))
target_list.append(element_to_append) return result.isoformat() if isinstance(result, datetime) else result.strftime("%Y-%m-%d")
return target_list
# $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
# ============================================= # =============================================
# 2.2. Hàm tổng hợp list: $agg(list, operation, field?) # 4. STRING FUNCTIONS
# ============================================= # =============================================
agg_match = re.match(r"^\$agg\(([^,]+),\s*'([^']+)'(?:,\s*['\"]?([^'\"]+)['\"]?)?\)$", expr.strip()) # $concat(str1, str2, ...)
if agg_match: if re.match(r'^\$concat\(', expr, re.IGNORECASE):
list_expr = agg_match.group(1).strip() match = re.match(r'^\$concat\((.*)\)$', expr, re.IGNORECASE)
operation = agg_match.group(2).strip() if match:
field_expr = agg_match.group(3).strip() if agg_match.group(3) else None args = split_args(match.group(1))
return ''.join(str(resolve_value(arg, context) or '') for arg in args)
# 1. Resolve the list # $upper, $lower, $trim
target_list = resolve_value(list_expr, context) string_single = {
if target_list is None: '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 = [] 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): if not isinstance(target_list, list):
return 0 return 0
# 2. Perform operation
if operation == 'count': if operation == 'count':
return len(target_list) return len(target_list)
if operation == 'sum': if operation == 'sum':
if not field_expr: if not field_expr:
return 0 return sum(to_number(item) for item in target_list)
total = 0 total = 0
for item in target_list: for item in target_list:
value = 0 value = item.get(field_expr) if isinstance(item, dict) else getattr(item, field_expr, 0)
if isinstance(item, dict): total += to_number(value)
value = item.get(field_expr)
else:
value = getattr(item, field_expr, 0)
try:
total += float(value or 0)
except (ValueError, TypeError):
pass
return total return total
print(f" [ERROR] Unknown $agg operation: {operation}") 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 return 0
if operation == 'min':
return min(values)
elif operation == 'max':
return max(values)
elif operation == 'avg':
return sum(values) / len(values)
# ============================================= # =============================================
# 3. Helper: Lấy giá trị từ context theo đường dẫn dotted # 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:
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
# $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)
# =============================================
# 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): def get_context_value(key_path):
if "." not in key_path: if not key_path:
return context.get(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(".") root, *rest = key_path.split(".")
val = context.get(root) val = context.get(root)
@@ -144,54 +496,46 @@ def resolve_value(expr, context):
if val is None: if val is None:
return 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) array_match = re.match(r"(\w+)\[(\d+)\]", r)
if array_match: if array_match:
attr_name = array_match.group(1) attr_name = array_match.group(1)
index = int(array_match.group(2)) 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) val = getattr(val, attr_name, None) if not isinstance(val, dict) else val.get(attr_name)
try: try:
if hasattr(val, 'all'): # Django QuerySet/Manager
val = val[index] val = val[index]
else: # List thông thường except:
val = val[index]
except (IndexError, TypeError, KeyError):
return None return None
else: else:
# 2. Xử lý truy cập thuộc tính hoặc dict key
if isinstance(val, dict): if isinstance(val, dict):
val = val.get(r) val = val.get(r)
else: else:
val = getattr(val, r, None) 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): if hasattr(val, 'all') and not isinstance(val, models.Model):
val = val.first() val = val.first()
if isinstance(val, Decimal):
return float(val)
return val return val
# ============================================= # =============================================
# 4. Xử lý placeholder kiểu {key} hoặc {obj.field} # 10. TEMPLATE STRING PROCESSING
# ============================================= # =============================================
pattern = re.compile(r"\{(\w+(\.\w+)*)\}") pattern = re.compile(r"\{(\w+(\.\w+)*)\}")
if pattern.search(expr): 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) single_match = pattern.fullmatch(expr)
if single_match: if single_match:
key = single_match.group(1) return get_context_value(single_match.group(1))
return get_context_value(key)
# Trường hợp chuỗi có nhiều placeholder: "Hello {customer.name}"
def replace_match(match): def replace_match(match):
key = match.group(1) val = get_context_value(match.group(1))
val = get_context_value(key)
return str(val) if val is not None else "" return str(val) if val is not None else ""
return pattern.sub(replace_match, expr) return pattern.sub(replace_match, expr)
# ============================================= # =============================================
# 5. Hỗ trợ $last_result và $last_result.field # 11. SUPPORT $last_result
# ============================================= # =============================================
if expr.startswith("$last_result"): if expr.startswith("$last_result"):
_, _, field = expr.partition(".") _, _, 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) 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: if "." in expr or expr in context:
return get_context_value(expr) return get_context_value(expr)
# =============================================
# 7. Trả về nguyên expr nếu không match gì
# =============================================
return expr 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