Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions routes/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
create_feedback_record,
find_feedback,
list_feedback,
next_feedback_status_options,
save_feedback,
update_feedback_status,
)
Expand Down Expand Up @@ -1123,12 +1124,15 @@ def admin_feedback():
status_filter = ''
if category_filter not in FEEDBACK_CATEGORY_LABELS:
category_filter = ''
feedback_records = list_feedback(
status=status_filter or None,
category=category_filter or None,
)
for record in feedback_records:
record['next_statuses'] = next_feedback_status_options(record.get('status'))
return render_template(
'admin_feedback.html',
feedback_records=list_feedback(
status=status_filter or None,
category=category_filter or None,
),
feedback_records=feedback_records,
feedback_statuses=FEEDBACK_STATUS_OPTIONS,
feedback_categories=FEEDBACK_CATEGORIES,
status_filter=status_filter,
Expand Down
12 changes: 12 additions & 0 deletions services/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@
}


def next_feedback_status_options(current_status: str | None) -> tuple[tuple[str, str], ...]:
"""Return only the valid next workflow choices in display order."""

allowed_statuses = _FEEDBACK_STATUS_TRANSITIONS.get(
str(current_status or "").strip(),
set(),
)
return tuple(
option for option in FEEDBACK_STATUS_OPTIONS if option[0] in allowed_statuses
)


class FeedbackValidationError(ValueError):
"""Raised when a feedback submission cannot be accepted safely."""

Expand Down
20 changes: 15 additions & 5 deletions templates/admin_feedback.html
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@
text-align: center;
}

.feedback-workflow-complete {
margin: 1rem 0 0;
color: var(--medium);
font-size: 0.875rem;
}

.feedback-filters,
.feedback-status-form {
display: flex;
Expand Down Expand Up @@ -251,15 +257,16 @@ <h2 id="feedback-{{ record.feedback_id }}-title">{{ record.subject }}</h2>
<li><span>{{ item.status_label }}</span> · <time datetime="{{ item.at }}">{{ item.at }}</time></li>
{% endfor %}
</ol>
{% if record.next_statuses %}
<form class="feedback-status-form" method="POST"
action="{{ url_for('main.admin_feedback_status', feedback_id=record.feedback_id) }}">
<input type="hidden" name="return_status" value="{{ status_filter }}">
<input type="hidden" name="return_category" value="{{ category_filter }}">
<div class="feedback-status-field">
<label for="feedback-status-{{ record.feedback_id }}">更新状态</label>
<select id="feedback-status-{{ record.feedback_id }}" name="status">
{% for value, label in feedback_statuses %}
<option value="{{ value }}" {% if record.status == value %}selected{% endif %}>{{ label }}</option>
<label for="feedback-status-{{ record.feedback_id }}">推进到下一状态</label>
<select id="feedback-status-{{ record.feedback_id }}" name="status" required>
{% for value, label in record.next_statuses %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</div>
Expand All @@ -268,8 +275,11 @@ <h2 id="feedback-{{ record.feedback_id }}-title">{{ record.subject }}</h2>
<textarea id="feedback-note-{{ record.feedback_id }}" name="note" maxlength="500"
placeholder="可选:记录分派、复现或解决说明"></textarea>
</div>
<button class="btn btn-primary" type="submit">保存状态</button>
<button class="btn btn-primary" type="submit">推进状态</button>
</form>
{% else %}
<p class="feedback-workflow-complete">该反馈已关闭,不能继续修改状态。</p>
{% endif %}
</article>
{% else %}
<div class="feedback-empty">还没有收到反馈。</div>
Expand Down
115 changes: 115 additions & 0 deletions tests/test_feedback_lifecycle_notifications_profile.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
import re
import tempfile
import unittest

Expand Down Expand Up @@ -74,6 +75,15 @@ def submit_feedback(self):
self.assertEqual(response.status_code, 302)
return response.headers['Location'].rstrip('/').split('/')[-1]

def status_options_for(self, page, feedback_id):
"""Return the status values rendered in one feedback item's form."""

action = (
f'action="/admin/feedback/{feedback_id}/status">'.encode('utf-8')
)
form = page.data.split(action, 1)[1].split(b'</form>', 1)[0]
return re.findall(rb'<option value="([^"]+)"(?:\s|>)', form)

def test_feedback_status_flow_updates_receipt_and_owned_notifications(self):
self.assertEqual(self.login('lifecycle_student', 'student_password').status_code, 302)
feedback_id = self.submit_feedback()
Expand Down Expand Up @@ -185,6 +195,111 @@ def test_status_transition_is_bounded_and_admin_only(self):
self.assertEqual(record['status'], 'received')
self.assertEqual(SystemLog.query.filter_by(log_type='反馈状态更新').count(), 0)

def test_status_update_preserves_only_valid_admin_filters(self):
feedback_id = self.submit_feedback()
self.assertEqual(self.login('lifecycle_admin', 'admin_password').status_code, 302)

preserved = self.client.post(
f'/admin/feedback/{feedback_id}/status',
data={
'status': 'triaged',
'return_status': 'triaged',
'return_category': 'bug',
},
follow_redirects=False,
)
self.assertEqual(preserved.status_code, 302)
self.assertEqual(
preserved.headers['Location'],
'/admin/feedback?status=triaged&category=bug',
)
filtered = self.client.get(preserved.headers['Location'])
self.assertEqual(filtered.status_code, 200)
self.assertIn('状态变更通知测试'.encode('utf-8'), filtered.data)

invalid_filter_feedback_id = self.submit_feedback()
discarded = self.client.post(
f'/admin/feedback/{invalid_filter_feedback_id}/status',
data={
'status': 'triaged',
'return_status': 'not-a-status',
'return_category': 'not-a-category',
},
follow_redirects=False,
)
self.assertEqual(discarded.status_code, 302)
self.assertEqual(discarded.headers['Location'], '/admin/feedback')

valid_status_feedback_id = self.submit_feedback()
valid_status_only = self.client.post(
f'/admin/feedback/{valid_status_feedback_id}/status',
data={
'status': 'triaged',
'return_status': 'triaged',
'return_category': 'not-a-category',
},
follow_redirects=False,
)
self.assertEqual(valid_status_only.status_code, 302)
self.assertEqual(
valid_status_only.headers['Location'],
'/admin/feedback?status=triaged',
)

valid_category_feedback_id = self.submit_feedback()
valid_category_only = self.client.post(
f'/admin/feedback/{valid_category_feedback_id}/status',
data={
'status': 'triaged',
'return_status': 'not-a-status',
'return_category': 'bug',
},
follow_redirects=False,
)
self.assertEqual(valid_category_only.status_code, 302)
self.assertEqual(
valid_category_only.headers['Location'],
'/admin/feedback?category=bug',
)

def test_status_selector_offers_only_reachable_transitions(self):
feedback_id = self.submit_feedback()
self.assertEqual(self.login('lifecycle_admin', 'admin_password').status_code, 302)

received = self.client.get('/admin/feedback')
self.assertEqual(
self.status_options_for(received, feedback_id),
[b'triaged'],
)

for status, expected_options in (
('triaged', [b'in_progress']),
('in_progress', [b'resolved']),
('resolved', [b'in_progress', b'closed']),
):
updated = self.client.post(
f'/admin/feedback/{feedback_id}/status',
data={'status': status},
follow_redirects=False,
)
self.assertEqual(updated.status_code, 302)
review = self.client.get('/admin/feedback')
self.assertEqual(
self.status_options_for(review, feedback_id),
expected_options,
)

closed = self.client.post(
f'/admin/feedback/{feedback_id}/status',
data={'status': 'closed'},
follow_redirects=False,
)
self.assertEqual(closed.status_code, 302)
review_closed = self.client.get('/admin/feedback')
status_action = f'action="/admin/feedback/{feedback_id}/status">'.encode('utf-8')
self.assertNotIn(status_action, review_closed.data)
self.assertIn('该反馈已关闭,不能继续修改状态。'.encode('utf-8'), review_closed.data)

def test_profile_public_scope_is_explicit_and_reversible(self):
self.assertEqual(self.login('lifecycle_student', 'student_password').status_code, 302)
edit = self.client.get('/edit_profile')
Expand Down