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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14 -2
View File
@@ -7,6 +7,18 @@ class AppConfig(AppConfig):
def ready(self):
import app.workflow_actions
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
@@ -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',
},
),
]
+18
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),
),
]
@@ -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),
),
]
+3 -1
View File
@@ -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)
-104
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
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.")
+535 -122
View File
@@ -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
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