Files
palladium-monitor/ticket-system/app.py
T
AI Agent 26940c4574 feat: initial commit - lightweight ticket system with Flask+SQLite
Features:
- User authentication (login/register)
- Ticket CRUD with status/priority/category
- Rich text description with paste-to-image
- File attachment upload (images/documents)
- Multi-select CC recipients
- Role-based permission control (admin/user)
- In-app notifications
- Statistics dashboard
- Gunicorn production deployment
2026-07-02 15:31:27 +08:00

776 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
轻量级工单系统 - Flask + SQLite
增强版:分类 / 优先级 / 标签 / 统计看板 / 登录 / 站内通知
"""
from datetime import datetime, timezone
import os
import uuid
from werkzeug.security import generate_password_hash, check_password_hash
from flask import (
Flask, request, redirect, render_template, session, abort, url_for,
jsonify, send_from_directory,
)
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config["SECRET_KEY"] = "ticket-system-secret-key-change-in-production"
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///tickets.db"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["UPLOAD_FOLDER"] = os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads")
app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024 # 20 MB
db = SQLAlchemy(app)
# ── 枚举定义 ──────────────────────────────────────────
STATUS = {
"open": "新建",
"in_progress": "进行中",
"pending": "待处理",
"resolved": "已完成",
"closed": "已关闭",
}
PRIORITY = {
"low": "",
"medium": "",
"high": "",
"urgent": "紧急",
}
CATEGORY = {
"bug": "技术故障",
"feature": "功能需求",
"consult": "咨询",
"incident": "事件处理",
"other": "其他",
}
# ── 数据模型 ──────────────────────────────────────────
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(200), nullable=False)
role = db.Column(db.String(20), default="user") # admin / user
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
class Notification(db.Model):
__tablename__ = "notifications"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text)
ticket_id = db.Column(db.Integer) # 关联工单(可选)
read = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
user = db.relationship("User", backref=db.backref("notifications", lazy=True))
class Ticket(db.Model):
__tablename__ = "tickets"
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text) # 支持 HTML(内联图片)
status = db.Column(db.String(20), default="open", nullable=False)
priority = db.Column(db.String(20), default="medium", nullable=False)
category = db.Column(db.String(20), default="other", nullable=False)
assignee = db.Column(db.String(100))
requester = db.Column(db.String(100))
cc_users = db.Column(db.String(500), default="") # 抄送人(逗号分隔)
tags = db.Column(db.String(500), default="")
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(
db.DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
comments = db.relationship(
"Comment", backref="ticket", lazy=True, cascade="all, delete-orphan",
)
class Comment(db.Model):
__tablename__ = "comments"
id = db.Column(db.Integer, primary_key=True)
ticket_id = db.Column(db.Integer, db.ForeignKey("tickets.id"), nullable=False)
author = db.Column(db.String(100), nullable=False)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
class Attachment(db.Model):
"""工单附件(图片 / 文件)"""
__tablename__ = "attachments"
id = db.Column(db.Integer, primary_key=True)
ticket_id = db.Column(db.Integer, db.ForeignKey("tickets.id"), nullable=False)
filename = db.Column(db.String(200), nullable=False) # 存储文件名(uuid
original_name = db.Column(db.String(300), nullable=False) # 原始文件名
content_type = db.Column(db.String(100)) # MIME type
size = db.Column(db.Integer, default=0) # 字节
is_image = db.Column(db.Boolean, default=False)
uploaded_by = db.Column(db.String(100))
uploaded_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
ticket = db.relationship("Ticket", backref=db.backref(
"attachments", lazy=True, cascade="all, delete-orphan",
))
# ── 工具函数 ──────────────────────────────────────────
def status_label(s):
return STATUS.get(s, s)
def priority_label(p):
return PRIORITY.get(p, p)
def category_label(c):
return CATEGORY.get(c, c)
def parse_tags(tags):
if not tags:
return []
return [t.strip() for t in tags.split(",") if t.strip()]
def current_user():
"""返回当前登录用户或 None"""
if "user_id" not in session:
return None
return db.session.get(User, session["user_id"])
def login_required(func):
"""登录守卫装饰器"""
def wrapper(*args, **kwargs):
if "user_id" not in session:
return redirect(url_for("login", next=request.path))
return func(*args, **kwargs)
wrapper.__name__ = func.__name__
return wrapper
def create_notification(user_id, title, content="", ticket_id=None):
"""为指定用户创建一条通知"""
n = Notification(
user_id=user_id,
title=title,
content=content,
ticket_id=ticket_id,
)
db.session.add(n)
def all_users():
"""返回所有用户列表,供下拉选择"""
return User.query.order_by(User.username).all()
def parse_cc(cc_str):
"""解析抄送人字段,返回列表"""
if not cc_str:
return []
return [u.strip() for u in cc_str.split(",") if u.strip()]
def can_access_ticket(user, ticket):
"""
权限控制:
- admin 可见所有工单
- 普通用户只能看到:自己是提交人 / 处理人 / 抄送人的工单
"""
if user.role == "admin":
return True
if ticket.requester == user.username:
return True
if ticket.assignee == user.username:
return True
if user.username in parse_cc(ticket.cc_users):
return True
return False
def accessible_tickets_query(me):
"""
返回当前用户可见的工单 Query。
- admin: 全部
- 普通用户: 提交人=me OR 处理人=me OR 在抄送人里
"""
if me.role == "admin":
return Ticket.query
return Ticket.query.filter(
db.or_(
Ticket.requester == me.username,
Ticket.assignee == me.username,
Ticket.cc_users.like(f"%{me.username}%"),
),
)
def _save_upload(file, ticket_id):
"""保存上传文件,返回 Attachment 实例或 None"""
me = current_user()
if not file or file.filename == "":
return None
ext = os.path.splitext(file.filename)[1].lower()
safe_name = f"{uuid.uuid4().hex}{ext}"
user_dir = os.path.join(app.config["UPLOAD_FOLDER"], str(ticket_id))
os.makedirs(user_dir, exist_ok=True)
path = os.path.join(user_dir, safe_name)
file.save(path)
content_type = file.content_type or "application/octet-stream"
is_image = content_type.startswith("image/")
att = Attachment(
ticket_id=ticket_id,
filename=safe_name,
original_name=file.filename,
content_type=content_type,
size=os.path.getsize(path),
is_image=is_image,
uploaded_by=me.username if me else "system",
)
db.session.add(att)
db.session.commit()
return att
@app.route("/api/upload", methods=["POST"])
@login_required
def api_upload():
"""JSON API:上传文件(含二进制流 / 粘贴图片)"""
ticket_id = request.args.get("t")
if not ticket_id:
return jsonify({"error": "缺少 ticket 参数"}), 400
ticket = db.get_or_404(Ticket, int(ticket_id))
user = current_user()
if not can_access_ticket(user, ticket):
return jsonify({"error": "无权限"}), 403
# 1) 普通文件上传 (multipart)
if "file" in request.files:
att = _save_upload(request.files["file"], ticket_id)
# 2) 二进制流(粘贴图片、拖拽)
elif request.data:
import base64
data = request.data
# 尝试 base64 解码(前端 paste 用 base64 传)
try:
if request.headers.get("Content-Type", "").startswith("image/"):
raw = data
elif request.headers.get("Content-Type") == "application/octet-stream":
raw = data
else:
raw = data
ext = ".png"
# 猜测扩展名
ct = request.headers.get("Content-Type", "")
ext_map = {
"image/jpeg": ".jpg", "image/png": ".png",
"image/gif": ".gif", "image/webp": ".webp",
}
ext = ext_map.get(ct, ".png")
safe_name = f"{uuid.uuid4().hex}{ext}"
user_dir = os.path.join(app.config["UPLOAD_FOLDER"], str(ticket_id))
os.makedirs(user_dir, exist_ok=True)
path = os.path.join(user_dir, safe_name)
with open(path, "wb") as f:
f.write(raw)
att = Attachment(
ticket_id=ticket_id, filename=safe_name,
original_name="pasted-image.png", content_type=ct or "image/png",
size=os.path.getsize(path), is_image=True,
uploaded_by=current_user().username if current_user() else "system",
)
db.session.add(att)
db.session.commit()
except Exception as e:
return jsonify({"error": str(e)}), 500
else:
return jsonify({"error": "没有上传文件"}), 400
return jsonify({
"ok": True,
"url": f"/uploads/{ticket_id}/{att.filename}",
"filename": att.original_name,
"is_image": att.is_image,
})
@app.route("/api/upload/delete/<int:aid>", methods=["POST"])
@login_required
def api_delete_attachment(aid):
"""删除附件"""
att = db.get_or_404(Attachment, aid)
os.remove(os.path.join(app.config["UPLOAD_FOLDER"], str(att.ticket_id), att.filename))
db.session.delete(att)
db.session.commit()
return jsonify({"ok": True})
@app.route("/uploads/<int:ticket_id>/<path:filename>")
@login_required
def serve_upload(ticket_id, filename):
"""提供上传文件下载"""
ticket = db.get_or_404(Ticket, ticket_id)
user = current_user()
if not can_access_ticket(user, ticket):
abort(403)
safe_dir = os.path.join(app.config["UPLOAD_FOLDER"], str(ticket_id))
return send_from_directory(safe_dir, filename)
@app.route("/login", methods=["GET", "POST"])
def login():
if "user_id" in session:
return redirect(url_for("dashboard"))
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
session["user_id"] = user.id
session["username"] = user.username
next_page = request.args.get("next") or url_for("dashboard")
return redirect(next_page)
return render_template(
"login.html", error="用户名或密码错误", username=username,
)
return render_template("login.html")
@app.route("/register", methods=["GET", "POST"])
def register():
if "user_id" in session:
return redirect(url_for("dashboard"))
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
password2 = request.form.get("password2", "")
if not username or not password:
return render_template(
"register.html", error="用户名和密码不能为空", username=username,
)
if password != password2:
return render_template(
"register.html", error="两次输入的密码不一致", username=username,
)
if User.query.filter_by(username=username).first():
return render_template(
"register.html", error="用户名已存在", username=username,
)
user = User(username=username, role="user")
user.set_password(password)
db.session.add(user)
db.session.commit()
session["user_id"] = user.id
session["username"] = user.username
return redirect(url_for("dashboard"))
return render_template("register.html")
@app.route("/logout")
def logout():
session.pop("user_id", None)
session.pop("username", None)
return redirect(url_for("login"))
# ── 通知路由 ──────────────────────────────────────────
@app.route("/notifications")
@login_required
def notifications():
user = current_user()
notifs = (
Notification.query.filter_by(user_id=user.id)
.order_by(Notification.created_at.desc())
.limit(50)
.all()
)
# 标记为已读
for n in notifs:
n.read = True
db.session.commit()
return render_template(
"notifications.html", notifications=notifs, datetime=datetime,
status_label=status_label,
)
@app.route("/notifications/count")
def notifications_count():
"""返回未读通知数(供前端角标使用)"""
count = 0
if "user_id" in session:
count = Notification.query.filter_by(
user_id=session["user_id"], read=False,
).count()
return str(count)
# ── 工单路由(全部需要登录) ──────────────────────────
@app.route("/")
@login_required
def dashboard():
"""统计看板"""
user = current_user()
me = user.username
base_query = accessible_tickets_query(user)
# 我的工单:提交人 + 处理人 + 抄送
my_tickets = base_query.count()
total = base_query.count()
by_status = {}
for key, label in STATUS.items():
by_status[key] = {"label": label, "count": base_query.filter_by(status=key).count()}
by_priority = {}
for key, label in PRIORITY.items():
by_priority[key] = {"label": label, "count": base_query.filter_by(priority=key).count()}
by_category = {}
for key, label in CATEGORY.items():
by_category[key] = {"label": label, "count": base_query.filter_by(category=key).count()}
recent = base_query.order_by(Ticket.updated_at.desc()).limit(10).all()
return render_template(
"dashboard.html",
total=total,
my_tickets=my_tickets,
by_status=by_status,
by_priority=by_priority,
by_category=by_category,
recent=recent,
STATUS=STATUS,
PRIORITY=PRIORITY,
CATEGORY=CATEGORY,
status_label=status_label,
priority_label=priority_label,
category_label=category_label,
)
@app.route("/tickets")
@login_required
def ticket_list():
"""工单列表(支持筛选)"""
user = current_user()
q_status = request.args.get("status", "")
q_priority = request.args.get("priority", "")
q_category = request.args.get("category", "")
q_tag = request.args.get("tag", "")
q_keyword = request.args.get("q", "")
query = accessible_tickets_query(user)
if q_status:
query = query.filter_by(status=q_status)
if q_priority:
query = query.filter_by(priority=q_priority)
if q_category:
query = query.filter_by(category=q_category)
if q_tag:
query = query.filter(Ticket.tags.like(f"%{q_tag}%"))
if q_keyword:
kw = f"%{q_keyword}%"
query = query.filter(
db.or_(Ticket.title.like(kw), Ticket.description.like(kw)),
)
tickets = query.order_by(Ticket.updated_at.desc()).all()
return render_template(
"ticket_list.html",
tickets=tickets,
STATUS=STATUS,
PRIORITY=PRIORITY,
CATEGORY=CATEGORY,
status_label=status_label,
priority_label=priority_label,
category_label=category_label,
parse_tags=parse_tags,
q_status=q_status,
q_priority=q_priority,
q_category=q_category,
q_tag=q_tag,
q_keyword=q_keyword,
)
@app.route("/ticket/<int:tid>")
@login_required
def ticket_detail(tid):
t = db.get_or_404(Ticket, tid)
user = current_user()
if not can_access_ticket(user, t):
abort(403)
atts = Attachment.query.filter_by(ticket_id=tid).order_by(
Attachment.uploaded_at.desc()
).all()
return render_template(
"ticket_detail.html",
ticket=t,
attachments=atts,
STATUS=STATUS,
PRIORITY=PRIORITY,
CATEGORY=CATEGORY,
status_label=status_label,
priority_label=priority_label,
category_label=category_label,
parse_tags=parse_tags,
parse_cc=parse_cc,
datetime=datetime,
all_users=all_users,
)
@app.route("/ticket/new", methods=["GET", "POST"])
@login_required
def ticket_new():
return _ticket_form(None)
@app.route("/ticket/<int:tid>/edit", methods=["GET", "POST"])
@login_required
def ticket_edit(tid):
t = db.get_or_404(Ticket, tid)
user = current_user()
if not can_access_ticket(user, t):
abort(403)
return _ticket_form(t)
def _ticket_form(ticket):
me = current_user()
if request.method == "POST":
title = request.form.get("title", "").strip()
desc = request.form.get("description", "").strip()
status = request.form.get("status", "open")
priority = request.form.get("priority", "medium")
category = request.form.get("category", "other")
assignee = request.form.get("assignee", "").strip()
requester = request.form.get("requester", "").strip()
cc_str = request.form.get("cc_users", "").strip()
tags = request.form.get("tags", "").strip()
if not title:
return "标题不能为空", 400
if ticket:
ticket.title = title
ticket.description = desc
ticket.status = status
ticket.priority = priority
ticket.category = category
ticket.assignee = assignee or None
ticket.requester = requester or None
ticket.cc_users = cc_str
ticket.tags = tags
# 分配人变更时通知新处理人
if assignee and assignee != me.username:
u = User.query.filter_by(username=assignee).first()
if u:
create_notification(
u.id,
f"工单 #{ticket.id} 已分配给你",
title,
ticket_id=ticket.id,
)
else:
ticket = Ticket(
title=title,
description=desc,
status=status,
priority=priority,
category=category,
assignee=assignee or None,
requester=me.username,
cc_users=cc_str,
tags=tags,
)
db.session.add(ticket)
db.session.commit()
# 通知新增的抄送人
cc_list = parse_cc(cc_str)
for uname in cc_list:
u = User.query.filter_by(username=uname).first()
if u and u.username != me.username:
create_notification(
u.id,
f"工单 #{ticket.id} 被抄送给你",
title,
ticket_id=ticket.id,
)
return redirect(f"/ticket/{ticket.id}")
return render_template(
"ticket_form.html",
ticket=ticket,
STATUS=STATUS,
PRIORITY=PRIORITY,
CATEGORY=CATEGORY,
is_edit=bool(ticket),
all_users=all_users,
current_username=me.username if me else "",
parse_cc=parse_cc,
)
@app.route("/ticket/<int:tid>/comment", methods=["POST"])
@login_required
def add_comment(tid):
t = db.get_or_404(Ticket, tid)
me = current_user()
content = request.form.get("content", "").strip()
if not content:
return "评论内容不能为空", 400
c = Comment(ticket_id=tid, author=me.username, content=content)
db.session.add(c)
# 通知:工单处理人 + 提交人 + 抄送人(避免通知自己)
notify_targets = set()
if t.assignee and t.assignee != me.username:
notify_targets.add(t.assignee)
if t.requester and t.requester != me.username:
notify_targets.add(t.requester)
for uname in parse_cc(t.cc_users):
if uname != me.username:
notify_targets.add(uname)
for uname in notify_targets:
u = User.query.filter_by(username=uname).first()
if u:
create_notification(
u.id,
f"工单 #{tid} 有新评论",
f"{me.username}: {content[:50]}{'...' if len(content) > 50 else ''}",
ticket_id=tid,
)
db.session.commit()
return redirect(f"/ticket/{tid}")
@app.route("/ticket/<int:tid>/close", methods=["POST"])
@login_required
def close_ticket(tid):
t = db.get_or_404(Ticket, tid)
t.status = "closed"
db.session.commit()
return redirect(f"/ticket/{tid}")
@app.route("/ticket/<int:tid>/delete", methods=["POST"])
@login_required
def delete_ticket(tid):
t = db.get_or_404(Ticket, tid)
db.session.delete(t)
db.session.commit()
return redirect("/tickets")
@app.template_filter("render_desc")
def render_desc_html(html):
"""
渲染描述中的内联图片,处理 contenteditable 的 div 包装。
只允许 <img> 和 <br> 标签,其他 HTML 转义。
contenteditable 默认会用 <div> 包裹段落,需要去掉。
"""
if not html:
return ""
from markupsafe import Markup, escape
import re
# Step 1: 将 <div>...</div> 替换为内容 + <br>,模拟内容editable的行为
def replace_div(m):
content = m.group(1)
return content + "\n"
html = re.sub(r'<div[^>]*>(.*?)</div>', replace_div, html, flags=re.DOTALL)
# Step 2: 将 <p>...</p> 替换为内容 + <br>
html = re.sub(r'<p[^>]*>(.*?)</p>', replace_div, html, flags=re.DOTALL)
# Step 3: 将 <br> 和 <br/> 替换为 \n
html = re.sub(r'<br\s*/?>', '\n', html)
# Step 4: 转义所有文本,但保留 <img> 标签
parts = []
last = 0
for m in re.finditer(r'<img[^>]*>', html):
if m.start() > last:
parts.append(escape(html[last:m.start()]))
parts.append(Markup(m.group(0)))
last = m.end()
if last < len(html):
parts.append(escape(html[last:]))
result = "".join(parts)
# 替换 \n 为 <br> 用于显示
return Markup(result).replace("\n", "<br>")
@app.errorhandler(403)
def forbidden(e):
return render_template("error.html", code=403, message="无权限访问此工单"), 403
@app.errorhandler(404)
def not_found(e):
return render_template("error.html", code=404, message="页面不存在"), 404
# ── 全局模板上下文 ────────────────────────────────────
@app.context_processor
def inject_globals():
"""每个模板都能访问的用户信息 + 未读通知数"""
unread = 0
if "user_id" in session:
unread = Notification.query.filter_by(
user_id=session["user_id"], read=False,
).count()
return dict(current_user=current_user, unread_count=unread)
# ── 启动 ──────────────────────────────────────────────
def seed_admin():
"""确保存在 admin 用户(密码 admin"""
if not User.query.filter_by(username="admin").first():
admin = User(username="admin", role="admin")
admin.set_password("admin")
db.session.add(admin)
db.session.commit()
print("✅ 已创建默认管理员: admin / admin")
if __name__ == "__main__":
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
with app.app_context():
db.create_all()
seed_admin()
app.run(host="0.0.0.0", port=5000, debug=True)