feat: add category-driven ticket workflows
This commit is contained in:
@@ -10,7 +10,7 @@ from ninja.errors import HttpError
|
||||
|
||||
from apps.accounts.api import require_permission
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.tickets.models import Ticket, TicketAttachment, TicketComment, TicketEvent
|
||||
from apps.tickets.models import Ticket, TicketAttachment, TicketCategory, TicketComment, TicketEvent
|
||||
from apps.tickets.schemas import (
|
||||
TicketAttachmentIn,
|
||||
TicketAttachmentOut,
|
||||
@@ -23,7 +23,7 @@ from apps.tickets.schemas import (
|
||||
TicketStatusIn,
|
||||
TicketUpdateIn,
|
||||
)
|
||||
from apps.tickets.services import transition_ticket
|
||||
from apps.tickets.services import TicketStatusError, transition_requires_remark, transition_ticket
|
||||
|
||||
router = Router(tags=["tickets"])
|
||||
|
||||
@@ -44,14 +44,35 @@ def serialize_ticket_user(user):
|
||||
}
|
||||
|
||||
|
||||
def serialize_ticket_category(category):
|
||||
if category is None:
|
||||
return None
|
||||
return {
|
||||
"id": category.id,
|
||||
"code": category.code,
|
||||
"name": category.name,
|
||||
"description": category.description,
|
||||
}
|
||||
|
||||
|
||||
def serialize_ticket(ticket: Ticket) -> dict:
|
||||
return {
|
||||
"id": ticket.id,
|
||||
"ticket_no": ticket.ticket_no,
|
||||
"title": ticket.title,
|
||||
"description": ticket.description,
|
||||
"category": serialize_ticket_category(ticket.category),
|
||||
"category_code": ticket.category.code if ticket.category_id else None,
|
||||
"status": ticket.status,
|
||||
"priority": ticket.priority,
|
||||
"problem_performance": ticket.problem_performance,
|
||||
"problem_object": ticket.problem_object,
|
||||
"submitter_name": ticket.submitter_name,
|
||||
"submitter_org": ticket.submitter_org,
|
||||
"ai_solution": ticket.ai_solution,
|
||||
"resolution": ticket.resolution_summary,
|
||||
"is_resolved": ticket.is_resolved,
|
||||
"replied_to_customer": ticket.replied_to_customer,
|
||||
"impact_scope": ticket.impact_scope,
|
||||
"environment": ticket.environment,
|
||||
"related_system": ticket.related_system,
|
||||
@@ -82,11 +103,21 @@ def create_ticket(request, payload: TicketIn):
|
||||
assignee = None
|
||||
if payload.assignee_id:
|
||||
assignee = get_user_model().objects.filter(id=payload.assignee_id).first()
|
||||
category = None
|
||||
if payload.category_code:
|
||||
category = TicketCategory.objects.filter(code=payload.category_code, enabled=True).first()
|
||||
if category is None:
|
||||
raise HttpError(400, "Unknown ticket category")
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no=next_ticket_no(),
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
category=category,
|
||||
priority=payload.priority,
|
||||
problem_performance=payload.problem_performance,
|
||||
problem_object=payload.problem_object,
|
||||
submitter_name=payload.submitter_name,
|
||||
submitter_org=payload.submitter_org,
|
||||
impact_scope=payload.impact_scope,
|
||||
environment=payload.environment,
|
||||
related_system=payload.related_system,
|
||||
@@ -97,7 +128,7 @@ def create_ticket(request, payload: TicketIn):
|
||||
TicketEvent.objects.create(
|
||||
ticket=ticket,
|
||||
event_type="created",
|
||||
to_value={"status": ticket.status},
|
||||
to_value={"status": ticket.status, "category_code": payload.category_code},
|
||||
operator=request.user if request.user.is_authenticated else None,
|
||||
)
|
||||
return serialize_ticket(ticket)
|
||||
@@ -110,22 +141,29 @@ def list_tickets(
|
||||
page_size: int = 20,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
category_code: str | None = None,
|
||||
q: str | None = None,
|
||||
related_system: str | None = None,
|
||||
assignee_id: int | None = None,
|
||||
view: str = "all",
|
||||
):
|
||||
require_permission(request, "ticket.view")
|
||||
queryset = Ticket.objects.select_related("requester", "assignee").all()
|
||||
queryset = Ticket.objects.select_related("requester", "assignee", "category").all()
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
if priority:
|
||||
queryset = queryset.filter(priority=priority)
|
||||
if category_code:
|
||||
queryset = queryset.filter(category__code=category_code)
|
||||
if q:
|
||||
queryset = queryset.filter(
|
||||
Q(ticket_no__icontains=q)
|
||||
| Q(title__icontains=q)
|
||||
| Q(description__icontains=q)
|
||||
| Q(problem_performance__icontains=q)
|
||||
| Q(problem_object__icontains=q)
|
||||
| Q(submitter_name__icontains=q)
|
||||
| Q(submitter_org__icontains=q)
|
||||
)
|
||||
if related_system:
|
||||
queryset = queryset.filter(related_system__icontains=related_system)
|
||||
@@ -185,7 +223,7 @@ def export_tickets(request):
|
||||
def get_ticket(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
return serialize_ticket(
|
||||
Ticket.objects.select_related("requester", "assignee").get(id=ticket_id)
|
||||
Ticket.objects.select_related("requester", "assignee", "category").get(id=ticket_id)
|
||||
)
|
||||
|
||||
|
||||
@@ -202,7 +240,15 @@ def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
|
||||
ticket = Ticket.objects.get(id=ticket_id)
|
||||
before = {
|
||||
"title": ticket.title,
|
||||
"category_code": ticket.category.code if ticket.category_id else None,
|
||||
"priority": ticket.priority,
|
||||
"problem_performance": ticket.problem_performance,
|
||||
"problem_object": ticket.problem_object,
|
||||
"submitter_name": ticket.submitter_name,
|
||||
"submitter_org": ticket.submitter_org,
|
||||
"resolution": ticket.resolution_summary,
|
||||
"is_resolved": ticket.is_resolved,
|
||||
"replied_to_customer": ticket.replied_to_customer,
|
||||
"impact_scope": ticket.impact_scope,
|
||||
"environment": ticket.environment,
|
||||
"related_system": ticket.related_system,
|
||||
@@ -211,6 +257,15 @@ def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
|
||||
if field == "assignee_id":
|
||||
user = get_user_model().objects.filter(id=value).first() if value else None
|
||||
ticket.assignee = user
|
||||
elif field == "category_code":
|
||||
category = (
|
||||
TicketCategory.objects.filter(code=value, enabled=True).first() if value else None
|
||||
)
|
||||
if value and category is None:
|
||||
raise HttpError(400, "Unknown ticket category")
|
||||
ticket.category = category
|
||||
elif field == "resolution":
|
||||
ticket.resolution_summary = value or ""
|
||||
else:
|
||||
setattr(ticket, field, value)
|
||||
ticket.save()
|
||||
@@ -227,15 +282,29 @@ def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
|
||||
@router.post("/{ticket_id}/status", response=TicketOut)
|
||||
def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn):
|
||||
require_permission(request, "ticket.edit")
|
||||
if not payload.remark.strip():
|
||||
raise HttpError(400, "Transition remark is required")
|
||||
ticket = Ticket.objects.get(id=ticket_id)
|
||||
updated = transition_ticket(
|
||||
ticket,
|
||||
ticket = Ticket.objects.select_related("category").get(id=ticket_id)
|
||||
category_code = ticket.category.code if ticket.category_id else None
|
||||
has_resolution = bool(payload.resolution.strip())
|
||||
if transition_requires_remark(
|
||||
ticket.status,
|
||||
payload.status,
|
||||
payload.remark,
|
||||
request.user if request.user.is_authenticated else None,
|
||||
)
|
||||
category_code,
|
||||
) and not payload.remark.strip():
|
||||
raise HttpError(400, "Transition remark is required")
|
||||
if payload.status == "resolved" and not (payload.remark.strip() or has_resolution):
|
||||
raise HttpError(400, "Resolution or transition remark is required")
|
||||
try:
|
||||
updated = transition_ticket(
|
||||
ticket,
|
||||
payload.status,
|
||||
payload.remark,
|
||||
request.user if request.user.is_authenticated else None,
|
||||
resolution=payload.resolution.strip(),
|
||||
is_resolved=payload.is_resolved,
|
||||
replied_to_customer=payload.replied_to_customer,
|
||||
)
|
||||
except TicketStatusError as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
return serialize_ticket(updated)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
STANDARD_CATEGORIES = [
|
||||
("business_issue", "业务问题", "业务进度、业务规则、业务状态确认等支持请求。", 10),
|
||||
("technical_issue", "技术问题", "系统异常、接口故障、服务不可用等技术排查请求。", 20),
|
||||
("data_issue", "数据问题", "数据缺失、数据不一致、采集或同步结果异常。", 30),
|
||||
("operation_issue", "操作问题", "用户操作指导、配置入口、权限使用等支持请求。", 40),
|
||||
]
|
||||
|
||||
|
||||
def seed_standard_categories(apps, schema_editor):
|
||||
TicketCategory = apps.get_model("tickets", "TicketCategory")
|
||||
for code, name, description, sort_order in STANDARD_CATEGORIES:
|
||||
TicketCategory.objects.update_or_create(
|
||||
code=code,
|
||||
defaults={
|
||||
"name": name,
|
||||
"description": description,
|
||||
"enabled": True,
|
||||
"sort_order": sort_order,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def remove_standard_categories(apps, schema_editor):
|
||||
TicketCategory = apps.get_model("tickets", "TicketCategory")
|
||||
category_codes = [category[0] for category in STANDARD_CATEGORIES]
|
||||
TicketCategory.objects.filter(code__in=category_codes).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tickets", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="ai_solution",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="is_resolved",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="problem_object",
|
||||
field=models.CharField(blank=True, max_length=160),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="problem_performance",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="replied_to_customer",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="resolution_summary",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="submitter_name",
|
||||
field=models.CharField(blank=True, max_length=80),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="submitter_org",
|
||||
field=models.CharField(blank=True, max_length=160),
|
||||
),
|
||||
migrations.RunPython(seed_standard_categories, remove_standard_categories),
|
||||
]
|
||||
@@ -48,6 +48,14 @@ class Ticket(models.Model):
|
||||
choices=TicketPriority.choices,
|
||||
default=TicketPriority.MEDIUM,
|
||||
)
|
||||
problem_performance = models.TextField(blank=True)
|
||||
problem_object = models.CharField(max_length=160, blank=True)
|
||||
submitter_name = models.CharField(max_length=80, blank=True)
|
||||
submitter_org = models.CharField(max_length=160, blank=True)
|
||||
ai_solution = models.TextField(blank=True)
|
||||
resolution_summary = models.TextField(blank=True)
|
||||
is_resolved = models.BooleanField(default=False)
|
||||
replied_to_customer = models.BooleanField(default=False)
|
||||
impact_scope = models.CharField(max_length=80, blank=True)
|
||||
environment = models.CharField(max_length=80, blank=True)
|
||||
related_system = models.CharField(max_length=120, blank=True)
|
||||
|
||||
@@ -6,7 +6,12 @@ from ninja import Schema
|
||||
class TicketIn(Schema):
|
||||
title: str
|
||||
description: str
|
||||
category_code: str | None = None
|
||||
priority: str = "medium"
|
||||
problem_performance: str = ""
|
||||
problem_object: str = ""
|
||||
submitter_name: str = ""
|
||||
submitter_org: str = ""
|
||||
impact_scope: str = ""
|
||||
environment: str = ""
|
||||
related_system: str = ""
|
||||
@@ -16,7 +21,15 @@ class TicketIn(Schema):
|
||||
class TicketUpdateIn(Schema):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
category_code: str | None = None
|
||||
priority: str | None = None
|
||||
problem_performance: str | None = None
|
||||
problem_object: str | None = None
|
||||
submitter_name: str | None = None
|
||||
submitter_org: str | None = None
|
||||
resolution: str | None = None
|
||||
is_resolved: bool | None = None
|
||||
replied_to_customer: bool | None = None
|
||||
impact_scope: str | None = None
|
||||
environment: str | None = None
|
||||
related_system: str | None = None
|
||||
@@ -26,6 +39,9 @@ class TicketUpdateIn(Schema):
|
||||
class TicketStatusIn(Schema):
|
||||
status: str
|
||||
remark: str = ""
|
||||
resolution: str = ""
|
||||
is_resolved: bool | None = None
|
||||
replied_to_customer: bool | None = None
|
||||
|
||||
|
||||
class TicketUserOut(Schema):
|
||||
@@ -34,13 +50,30 @@ class TicketUserOut(Schema):
|
||||
display_name: str
|
||||
|
||||
|
||||
class TicketCategoryOut(Schema):
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class TicketOut(Schema):
|
||||
id: int
|
||||
ticket_no: str
|
||||
title: str
|
||||
description: str
|
||||
category: TicketCategoryOut | None = None
|
||||
category_code: str | None = None
|
||||
status: str
|
||||
priority: str
|
||||
problem_performance: str
|
||||
problem_object: str
|
||||
submitter_name: str
|
||||
submitter_org: str
|
||||
ai_solution: str
|
||||
resolution: str
|
||||
is_resolved: bool
|
||||
replied_to_customer: bool
|
||||
impact_scope: str
|
||||
environment: str
|
||||
related_system: str
|
||||
|
||||
@@ -8,7 +8,7 @@ class TicketStatusError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
ALLOWED_TRANSITIONS: dict[str, set[str]] = {
|
||||
STANDARD_TRANSITIONS: dict[str, set[str]] = {
|
||||
"pending_info": {"pending", "closed"},
|
||||
"pending": {"in_progress", "closed"},
|
||||
"in_progress": {"pending_confirm"},
|
||||
@@ -17,26 +17,113 @@ ALLOWED_TRANSITIONS: dict[str, set[str]] = {
|
||||
"closed": set(),
|
||||
}
|
||||
|
||||
SHORT_TRANSITIONS: dict[str, set[str]] = {
|
||||
"pending_info": {"pending", "resolved", "closed"},
|
||||
"pending": {"resolved", "closed"},
|
||||
"in_progress": {"resolved", "closed"},
|
||||
"pending_confirm": {"resolved", "closed"},
|
||||
"resolved": {"closed"},
|
||||
"closed": set(),
|
||||
}
|
||||
|
||||
def validate_status_transition(from_status: str, to_status: str) -> None:
|
||||
if to_status not in ALLOWED_TRANSITIONS.get(from_status, set()):
|
||||
MEDIUM_TRANSITIONS: dict[str, set[str]] = {
|
||||
"pending_info": {"pending", "closed"},
|
||||
"pending": {"in_progress", "resolved", "closed"},
|
||||
"in_progress": {"resolved", "pending_confirm", "closed"},
|
||||
"pending_confirm": {"resolved", "in_progress", "closed"},
|
||||
"resolved": {"closed"},
|
||||
"closed": set(),
|
||||
}
|
||||
|
||||
CATEGORY_TRANSITION_POLICIES = {
|
||||
"business_issue": SHORT_TRANSITIONS,
|
||||
"operation_issue": SHORT_TRANSITIONS,
|
||||
"data_issue": MEDIUM_TRANSITIONS,
|
||||
"technical_issue": STANDARD_TRANSITIONS,
|
||||
}
|
||||
|
||||
# Backward compatible export used by older tests and internal imports.
|
||||
ALLOWED_TRANSITIONS = STANDARD_TRANSITIONS
|
||||
|
||||
|
||||
def get_ticket_category_code(ticket: Ticket) -> str | None:
|
||||
if ticket.category_id and ticket.category:
|
||||
return ticket.category.code
|
||||
return None
|
||||
|
||||
|
||||
def get_allowed_transitions(from_status: str, category_code: str | None = None) -> set[str]:
|
||||
transitions = CATEGORY_TRANSITION_POLICIES.get(category_code or "", STANDARD_TRANSITIONS)
|
||||
return transitions.get(from_status, set())
|
||||
|
||||
|
||||
def validate_status_transition(
|
||||
from_status: str,
|
||||
to_status: str,
|
||||
category_code: str | None = None,
|
||||
) -> None:
|
||||
if to_status not in get_allowed_transitions(from_status, category_code):
|
||||
raise TicketStatusError(f"Invalid ticket status transition: {from_status} -> {to_status}")
|
||||
|
||||
|
||||
def transition_ticket(ticket: Ticket, to_status: str, remark: str = "", operator=None) -> Ticket:
|
||||
def transition_requires_remark(
|
||||
from_status: str,
|
||||
to_status: str,
|
||||
category_code: str | None = None,
|
||||
) -> bool:
|
||||
if to_status == "resolved":
|
||||
return False
|
||||
if category_code in {"business_issue", "operation_issue"} and to_status == "closed":
|
||||
return False
|
||||
return to_status in {"in_progress", "pending_confirm", "closed"}
|
||||
|
||||
|
||||
def transition_ticket(
|
||||
ticket: Ticket,
|
||||
to_status: str,
|
||||
remark: str = "",
|
||||
operator=None,
|
||||
*,
|
||||
resolution: str = "",
|
||||
is_resolved: bool | None = None,
|
||||
replied_to_customer: bool | None = None,
|
||||
) -> Ticket:
|
||||
from_status = ticket.status
|
||||
validate_status_transition(from_status, to_status)
|
||||
category_code = get_ticket_category_code(ticket)
|
||||
validate_status_transition(from_status, to_status, category_code)
|
||||
ticket.status = to_status
|
||||
if to_status == "resolved":
|
||||
ticket.resolved_at = timezone.now()
|
||||
ticket.is_resolved = True if is_resolved is None else is_resolved
|
||||
elif is_resolved is not None:
|
||||
ticket.is_resolved = is_resolved
|
||||
if to_status == "closed":
|
||||
ticket.closed_at = timezone.now()
|
||||
ticket.save(update_fields=["status", "resolved_at", "closed_at", "updated_at"])
|
||||
if resolution:
|
||||
ticket.resolution_summary = resolution
|
||||
if replied_to_customer is not None:
|
||||
ticket.replied_to_customer = replied_to_customer
|
||||
ticket.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"resolved_at",
|
||||
"closed_at",
|
||||
"resolution_summary",
|
||||
"is_resolved",
|
||||
"replied_to_customer",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
TicketEvent.objects.create(
|
||||
ticket=ticket,
|
||||
event_type="status_change",
|
||||
from_value={"status": from_status},
|
||||
to_value={"status": to_status},
|
||||
to_value={
|
||||
"status": to_status,
|
||||
"resolution": ticket.resolution_summary,
|
||||
"is_resolved": ticket.is_resolved,
|
||||
"replied_to_customer": ticket.replied_to_customer,
|
||||
},
|
||||
operator=operator,
|
||||
remark=remark,
|
||||
)
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from apps.tickets.models import Ticket, TicketEvent
|
||||
from apps.tickets.models import Ticket, TicketCategory, TicketEvent
|
||||
|
||||
|
||||
def test_create_ticket_returns_ticket_detail(admin_client, db):
|
||||
TicketCategory.objects.get_or_create(code="business_issue", defaults={"name": "业务问题"})
|
||||
response = admin_client.post(
|
||||
"/api/tickets",
|
||||
data={
|
||||
"title": "Payment callback fails",
|
||||
"description": "Callback returns HTTP 500 in production",
|
||||
"category_code": "business_issue",
|
||||
"priority": "high",
|
||||
"problem_performance": "采集进度未更新",
|
||||
"problem_object": "客户 A",
|
||||
"submitter_name": "张三",
|
||||
"submitter_org": "华东分公司",
|
||||
"impact_scope": "payment users",
|
||||
"environment": "production",
|
||||
"related_system": "payment",
|
||||
@@ -21,6 +27,10 @@ def test_create_ticket_returns_ticket_detail(admin_client, db):
|
||||
payload = response.json()
|
||||
assert payload["ticket_no"].startswith("TK")
|
||||
assert payload["title"] == "Payment callback fails"
|
||||
assert payload["category"]["name"] == "业务问题"
|
||||
assert payload["category_code"] == "business_issue"
|
||||
assert payload["problem_object"] == "客户 A"
|
||||
assert payload["submitter_org"] == "华东分公司"
|
||||
assert payload["status"] == "pending"
|
||||
assert TicketEvent.objects.filter(ticket_id=payload["id"], event_type="created").exists()
|
||||
|
||||
@@ -129,6 +139,59 @@ def test_transition_ticket_requires_non_empty_remark(admin_client, db):
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_business_ticket_can_be_resolved_directly_with_resolution(admin_client, db):
|
||||
category, _ = TicketCategory.objects.get_or_create(
|
||||
code="business_issue",
|
||||
defaults={"name": "业务问题"},
|
||||
)
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no="TK202607010104",
|
||||
title="查询采集进度",
|
||||
description="客户需要确认采集进度",
|
||||
status="pending",
|
||||
category=category,
|
||||
)
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/tickets/{ticket.id}/status",
|
||||
data={
|
||||
"status": "resolved",
|
||||
"resolution": "已查询采集进度,当前已完成 80%。",
|
||||
"replied_to_customer": True,
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
ticket.refresh_from_db()
|
||||
assert ticket.status == "resolved"
|
||||
assert ticket.resolution_summary == "已查询采集进度,当前已完成 80%。"
|
||||
assert ticket.is_resolved is True
|
||||
assert ticket.replied_to_customer is True
|
||||
|
||||
|
||||
def test_technical_ticket_cannot_be_resolved_directly(admin_client, db):
|
||||
category, _ = TicketCategory.objects.get_or_create(
|
||||
code="technical_issue",
|
||||
defaults={"name": "技术问题"},
|
||||
)
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no="TK202607010105",
|
||||
title="生产接口异常",
|
||||
description="接口 500",
|
||||
status="pending",
|
||||
category=category,
|
||||
)
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/tickets/{ticket.id}/status",
|
||||
data={"status": "resolved", "resolution": "已恢复"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_get_ticket_events_returns_real_timeline_with_operator(admin_client, db):
|
||||
user_model = get_user_model()
|
||||
operator = user_model.objects.get(username="admin")
|
||||
|
||||
@@ -20,3 +20,16 @@ def test_in_progress_ticket_must_not_resolve_directly():
|
||||
def test_resolved_ticket_cannot_move_back_to_in_progress():
|
||||
with pytest.raises(TicketStatusError, match="resolved -> in_progress"):
|
||||
validate_status_transition("resolved", "in_progress")
|
||||
|
||||
|
||||
def test_business_ticket_can_resolve_directly_from_pending():
|
||||
validate_status_transition("pending", "resolved", "business_issue")
|
||||
|
||||
|
||||
def test_technical_ticket_cannot_resolve_directly_from_pending():
|
||||
with pytest.raises(TicketStatusError, match="pending -> resolved"):
|
||||
validate_status_transition("pending", "resolved", "technical_issue")
|
||||
|
||||
|
||||
def test_data_ticket_can_resolve_from_in_progress():
|
||||
validate_status_transition("in_progress", "resolved", "data_issue")
|
||||
|
||||
Reference in New Issue
Block a user