morozovka-portal/app/modules/projects_routes.py

587 lines
35 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.

"""Роуты проектов: каталог, карточка, участники, лента, файлы, канбан."""
from datetime import datetime, timezone
from flask import render_template, request, redirect, url_for, session, jsonify
from sqlmodel import Session, select
from ..db import engine
from ..models import (
Project, ProjectMember, ProjectPost, ProjectPostAttachment,
ProjectSubscription, ProjectTask, PortalFile, User,
)
from ..helpers import (
login_required, require_role, project_permissions,
normalize_tags, get_all_tags, resolve_gitea_repo,
_project_subscription, _project_subscriber_ids,
_save_uploaded_files, notify_user, notify_users,
PROJECT_ROLE_RANK, TASK_STATUSES, TASK_STATUS_LABELS, TASK_PRIORITIES,
)
from .. import gitea_api
def register(app):
@app.route("/projects")
def projects_list():
user_role = session.get("role")
user_id = session.get("user_id")
current_tag = (request.args.get("tag") or "").strip().lower()
current_status = (request.args.get("status") or "").strip()
with Session(engine) as db_session:
all_projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all()
member_map = {}
if user_id:
for m in db_session.exec(select(ProjectMember).where(ProjectMember.user_id == user_id)).all():
member_map[m.project_id] = m.role
all_tags = get_all_tags(db_session)
filtered = []
for p in all_projects:
if p.visibility == "public": filtered.append(p)
elif p.visibility == "internal" and user_id: filtered.append(p)
elif p.visibility == "private" and user_id and (
p.owner_id == user_id or p.id in member_map or user_role == "admin"
): filtered.append(p)
if current_status:
filtered = [p for p in filtered if p.status == current_status]
if current_tag:
filtered = [p for p in filtered
if p.tags and current_tag in [t.strip().lower() for t in p.tags.split(",")]]
return render_template("projects.html", projects=filtered, role=user_role, user_id=user_id,
member_map=member_map, all_tags=all_tags,
current_tag=current_tag, current_status=current_status)
@app.route("/projects/new", methods=["GET", "POST"])
@require_role(["admin", "professor", "lead"])
def project_new():
if request.method == "POST":
with Session(engine) as db_session:
new_proj = Project(
title=request.form.get("title"),
description=request.form.get("description"),
status=request.form.get("status"),
visibility=request.form.get("visibility"),
gitea_url=(request.form.get("gitea_url") or "").strip() or None,
gitea_repo=resolve_gitea_repo(request.form),
tags=normalize_tags(request.form.get("tags")),
start_date=(request.form.get("start_date") or "").strip() or None,
end_date=(request.form.get("end_date") or "").strip() or None,
contact_person=(request.form.get("contact_person") or "").strip() or None,
owner_id=session.get("user_id"))
db_session.add(new_proj); db_session.flush()
proj_id = new_proj.id
db_session.add(ProjectMember(
project_id=proj_id, user_id=session.get("user_id"),
role="manager", can_edit_files=True, can_post_feed=True,
can_edit_any_post=True, can_manage_members=True))
db_session.commit()
return redirect(url_for("project_detail", project_id=proj_id))
return render_template("project_form.html", project=None, is_edit=False)
@app.route("/projects/<int:project_id>")
def project_detail(project_id):
user_id = session.get("user_id")
user_role = session.get("role")
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = None
if user_id:
membership = db_session.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == user_id)).first()
perms = project_permissions(proj, user_id, user_role, membership)
if proj.visibility == "private" and not perms["is_admin"] and not perms["is_owner"] and not membership:
return "403: Это приватный проект", 403
if proj.visibility == "internal" and not user_id:
return redirect(url_for("login"))
members = db_session.exec(
select(ProjectMember, User).join(User, ProjectMember.user_id == User.id)
.where(ProjectMember.project_id == project_id).order_by(User.username)).all()
files = db_session.exec(
select(PortalFile, User.username).join(User, PortalFile.uploaded_by == User.id)
.where(PortalFile.project_id == project_id)
.order_by(PortalFile.uploaded_at.desc())).all()
task_counts = {s: 0 for s in TASK_STATUSES}
for t in db_session.exec(select(ProjectTask).where(ProjectTask.project_id == project_id)).all():
if t.status in task_counts: task_counts[t.status] += 1
posts = db_session.exec(
select(ProjectPost).where(ProjectPost.project_id == project_id)
.order_by(ProjectPost.is_pinned.desc(), ProjectPost.created_at.desc())).all()
post_ids = [p.id for p in posts]
attachments_by_post = {}
if post_ids:
for att in db_session.exec(select(ProjectPostAttachment)
.where(ProjectPostAttachment.post_id.in_(post_ids))).all():
attachments_by_post.setdefault(att.post_id, []).append(att)
file_ids = [a.file_id for arr in attachments_by_post.values() for a in arr]
files_map = {}
if file_ids:
for f in db_session.exec(select(PortalFile).where(PortalFile.id.in_(file_ids))).all():
files_map[f.id] = f
author_ids = {p.author_id for p in posts}
authors_map = {}
if author_ids:
for u in db_session.exec(select(User).where(User.id.in_(author_ids))).all():
authors_map[u.id] = u
my_sub = _project_subscription(db_session, project_id, user_id)
return render_template("project_detail.html", project=proj, members=members, files=files,
perms=perms, user_id=user_id, role=user_role,
active_tab="overview", task_counts=task_counts,
posts=posts, attachments_by_post=attachments_by_post,
files_map=files_map, authors_map=authors_map, my_sub=my_sub)
@app.route("/projects/<int:project_id>/gitea-stats")
def project_gitea_stats(project_id):
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return jsonify({"ok": False, "error": "not_found"}), 404
membership = None
if session.get("user_id"):
membership = db_session.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == session.get("user_id"))).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if proj.visibility == "private" and not perms["is_admin"] and not perms["is_owner"] and not membership:
return jsonify({"ok": False, "error": "forbidden"}), 403
repo = proj.gitea_repo or (gitea_api.extract_repo_from_url(proj.gitea_url) if proj.gitea_url else None)
if not repo:
return jsonify({"ok": False, "error": "no_repo",
"message": "У проекта не указан репозиторий."})
return jsonify(gitea_api.get_project_stats(repo))
@app.route("/projects/<int:project_id>/edit", methods=["GET", "POST"])
@login_required
def project_edit(project_id):
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = db_session.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == session.get("user_id"))).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if not perms["can_manage_members"] and not perms["is_admin"] and not perms["is_owner"]:
return "403: Нет прав", 403
if request.method == "POST":
proj.title = request.form.get("title")
proj.description = request.form.get("description")
proj.status = request.form.get("status")
proj.visibility = request.form.get("visibility")
proj.gitea_url = (request.form.get("gitea_url") or "").strip() or None
proj.gitea_repo = resolve_gitea_repo(request.form, proj.gitea_url, proj.gitea_repo)
proj.tags = normalize_tags(request.form.get("tags"))
proj.start_date = (request.form.get("start_date") or "").strip() or None
proj.end_date = (request.form.get("end_date") or "").strip() or None
proj.contact_person = (request.form.get("contact_person") or "").strip() or None
db_session.add(proj); db_session.commit()
return redirect(url_for("project_detail", project_id=proj.id))
return render_template("project_form.html", project=proj, is_edit=True)
@app.route("/projects/<int:project_id>/members/add", methods=["POST"])
@login_required
def project_add_member(project_id):
username = (request.form.get("username") or "").strip()
role = request.form.get("role") or "viewer"
if role not in PROJECT_ROLE_RANK: role = "viewer"
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = db_session.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == session.get("user_id"))).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if not perms["can_manage_members"]: return "403: Нет прав", 403
user_to_add = db_session.exec(select(User).where(User.username == username)).first()
if not user_to_add: return redirect(url_for("project_detail", project_id=project_id))
existing = db_session.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == user_to_add.id)).first()
if not existing:
is_mgr = (role == "manager")
is_ed = (role in ("editor", "manager"))
db_session.add(ProjectMember(project_id=project_id, user_id=user_to_add.id, role=role,
can_edit_files=is_ed, can_post_feed=is_ed,
can_edit_any_post=is_mgr, can_manage_members=is_mgr))
db_session.commit()
notify_user(user_to_add.id, f"Вас добавили в «{proj.title}»",
f"Роль: {role}",
link_url=url_for("project_detail", project_id=project_id),
kind="info")
return redirect(url_for("project_detail", project_id=project_id))
@app.route("/projects/<int:project_id>/members/<int:user_id>/permissions", methods=["POST"])
@login_required
def project_member_permissions(project_id, user_id):
with Session(engine) as s:
proj = s.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = s.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == session.get("user_id"))).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if not perms["can_manage_members"]: return "403: Нет прав", 403
if user_id == proj.owner_id: return "Нельзя менять права владельца", 400
target = s.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == user_id)).first()
if target:
target.can_view_files = request.form.get("can_view_files") == "on"
target.can_edit_files = request.form.get("can_edit_files") == "on"
target.can_view_repo = request.form.get("can_view_repo") == "on"
target.can_post_feed = request.form.get("can_post_feed") == "on"
target.can_edit_own_posts = request.form.get("can_edit_own_posts") == "on"
target.can_edit_any_post = request.form.get("can_edit_any_post") == "on"
target.can_manage_members = request.form.get("can_manage_members") == "on"
if target.can_manage_members: target.role = "manager"
elif target.can_edit_files or target.can_post_feed: target.role = "editor"
else: target.role = "viewer"
s.add(target); s.commit()
return redirect(url_for("project_detail", project_id=project_id))
@app.route("/projects/<int:project_id>/members/<int:user_id>/remove", methods=["POST"])
@login_required
def project_remove_member(project_id, user_id):
with Session(engine) as s:
proj = s.get(Project, project_id)
if not proj: return "Проект не найден", 404
my_membership = s.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == session.get("user_id"))).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), my_membership)
if not perms["can_manage_members"]: return "403: Нет прав", 403
if user_id == proj.owner_id: return "Нельзя удалить владельца", 400
to_remove = s.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == user_id)).first()
if to_remove: s.delete(to_remove); s.commit()
return redirect(url_for("project_detail", project_id=project_id))
@app.route("/projects/<int:project_id>/subscribe", methods=["POST"])
@login_required
def project_subscribe_toggle(project_id):
user_id = session.get("user_id")
with Session(engine) as s:
existing = _project_subscription(s, project_id, user_id)
if existing: s.delete(existing); s.commit()
else:
s.add(ProjectSubscription(project_id=project_id, user_id=user_id))
s.commit()
return redirect(url_for("project_detail", project_id=project_id))
@app.route("/projects/<int:project_id>/posts/new", methods=["POST"])
@login_required
def project_post_new(project_id):
user_id = session.get("user_id")
post_id = None
with Session(engine) as s:
proj = s.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = s.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == user_id)).first()
perms = project_permissions(proj, user_id, session.get("role"), membership)
if not perms["can_post_feed"]: return "403: Нет прав", 403
title = (request.form.get("title") or "").strip()
if not title: return redirect(url_for("project_detail", project_id=project_id))
post = ProjectPost(project_id=project_id, author_id=user_id, title=title,
body=(request.form.get("body") or "").strip() or None,
is_pinned=bool(request.form.get("is_pinned")))
s.add(post); s.flush()
post_id = post.id; s.commit()
proj_title = proj.title
files = request.files.getlist("files") or []
saved_ids = _save_uploaded_files(files, user_id, project_id=project_id,
upload_folder=app.config['UPLOAD_FOLDER'])
if saved_ids:
with Session(engine) as s2:
for fid in saved_ids:
s2.add(ProjectPostAttachment(post_id=post_id, file_id=fid))
s2.commit()
with Session(engine) as s3:
sub_ids = _project_subscriber_ids(s3, project_id)
recipients = [uid for uid in sub_ids if uid != user_id]
if recipients:
notify_users(recipients, f"Новый пост в «{proj_title}»",
request.form.get("title", ""),
link_url=url_for("project_detail", project_id=project_id), kind="info")
return redirect(url_for("project_detail", project_id=project_id))
@app.route("/projects/<int:project_id>/posts/<int:post_id>/edit", methods=["POST"])
@login_required
def project_post_edit(project_id, post_id):
user_id = session.get("user_id")
with Session(engine) as s:
proj = s.get(Project, project_id)
post = s.get(ProjectPost, post_id)
if not proj or not post or post.project_id != project_id: return "Не найдено", 404
membership = s.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == user_id)).first()
perms = project_permissions(proj, user_id, session.get("role"), membership)
can_edit = perms["can_edit_any_post"] or (perms["can_edit_own_posts"] and post.author_id == user_id)
if not can_edit: return "403: Нет прав", 403
post.title = (request.form.get("title") or post.title).strip()
post.body = (request.form.get("body") or "").strip() or None
post.is_pinned = bool(request.form.get("is_pinned"))
post.updated_at = datetime.now(timezone.utc)
s.add(post); s.commit()
return redirect(url_for("project_detail", project_id=project_id))
@app.route("/projects/<int:project_id>/posts/<int:post_id>/delete", methods=["POST"])
@login_required
def project_post_delete(project_id, post_id):
user_id = session.get("user_id")
with Session(engine) as s:
proj = s.get(Project, project_id)
post = s.get(ProjectPost, post_id)
if not proj or not post or post.project_id != project_id: return "Не найдено", 404
membership = s.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == user_id)).first()
perms = project_permissions(proj, user_id, session.get("role"), membership)
can_delete = (perms["is_admin"] or perms["is_owner"] or perms["can_edit_any_post"]
or (perms["can_edit_own_posts"] and post.author_id == user_id))
if not can_delete: return "403: Нет прав", 403
for att in s.exec(select(ProjectPostAttachment)
.where(ProjectPostAttachment.post_id == post_id)).all():
s.delete(att)
s.delete(post); s.commit()
return redirect(url_for("project_detail", project_id=project_id))
@app.route("/projects/<int:project_id>/delete", methods=["POST"])
@login_required
def project_delete(project_id):
with Session(engine) as s:
proj = s.get(Project, project_id)
if not proj: return redirect(url_for("projects_list"))
if not (session.get("role") == "admin" or proj.owner_id == session.get("user_id")):
return "403: Только владелец или админ", 403
try:
for f in s.exec(select(PortalFile).where(PortalFile.project_id == project_id)).all():
f.project_id = None; s.add(f)
s.flush()
for t in s.exec(select(ProjectTask).where(ProjectTask.project_id == project_id)).all():
s.delete(t)
s.flush()
for p in s.exec(select(ProjectPost).where(ProjectPost.project_id == project_id)).all():
for a in s.exec(select(ProjectPostAttachment)
.where(ProjectPostAttachment.post_id == p.id)).all():
s.delete(a)
s.delete(p)
for sub in s.exec(select(ProjectSubscription)
.where(ProjectSubscription.project_id == project_id)).all():
s.delete(sub)
for m in s.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)).all():
s.delete(m)
s.flush()
s.delete(proj); s.commit()
except Exception as e:
s.rollback()
print(f"[project_delete] {e}", flush=True)
return f"Ошибка: {e}", 500
return redirect(url_for("projects_list"))
@app.route("/projects/<int:project_id>/files/upload", methods=["POST"])
@login_required
def project_file_upload(project_id):
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = db_session.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == session.get("user_id"))).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if not perms["can_upload"]: return "403: Нет прав", 403
try:
file = request.files.get('file')
except Exception:
return "Ошибка разбора", 400
if not file or file.filename == '':
return redirect(url_for("project_detail", project_id=project_id))
saved = _save_uploaded_files([file], session.get("user_id"),
project_id=project_id,
upload_folder=app.config['UPLOAD_FOLDER'])
if not saved: return "Ошибка сохранения", 500
return redirect(url_for("project_detail", project_id=project_id))
# ─── КАНБАН ───────────────────────────────────────────────────────
@app.route("/projects/<int:project_id>/board")
def project_board(project_id):
user_id = session.get("user_id")
user_role = session.get("role")
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = None
if user_id:
membership = db_session.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == user_id)).first()
perms = project_permissions(proj, user_id, user_role, membership)
if proj.visibility == "private" and not perms["is_admin"] and not perms["is_owner"] and not membership:
return "403", 403
if proj.visibility == "internal" and not user_id: return redirect(url_for("login"))
members = db_session.exec(
select(ProjectMember, User).join(User, ProjectMember.user_id == User.id)
.where(ProjectMember.project_id == project_id).order_by(User.username)).all()
tasks = db_session.exec(
select(ProjectTask).where(ProjectTask.project_id == project_id)
.order_by(ProjectTask.position, ProjectTask.id)).all()
user_ids = set()
for t in tasks:
if t.assignee_id: user_ids.add(t.assignee_id)
if t.created_by: user_ids.add(t.created_by)
users_map = {}
if user_ids:
for u in db_session.exec(select(User).where(User.id.in_(user_ids))).all():
users_map[u.id] = u
columns = {s: [] for s in TASK_STATUSES}
for t in tasks:
if t.status in columns:
columns[t.status].append({
"task": t,
"assignee": users_map.get(t.assignee_id) if t.assignee_id else None,
"author": users_map.get(t.created_by)})
task_counts = {s: len(columns[s]) for s in TASK_STATUSES}
return render_template("project_board.html", project=proj, members=members,
columns=columns, perms=perms, user_id=user_id, role=user_role,
active_tab="board", task_counts=task_counts,
TASK_STATUSES=TASK_STATUSES,
TASK_STATUS_LABELS=TASK_STATUS_LABELS,
TASK_PRIORITIES=TASK_PRIORITIES)
@app.route("/projects/<int:project_id>/tasks/new", methods=["POST"])
@login_required
def task_new(project_id):
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = db_session.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == session.get("user_id"))).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if not perms["can_edit"]: return "403", 403
title = (request.form.get("title") or "").strip()
if not title: return redirect(url_for("project_board", project_id=project_id))
status = request.form.get("status") or "backlog"
if status not in TASK_STATUSES: status = "backlog"
priority = request.form.get("priority") or "medium"
if priority not in TASK_PRIORITIES: priority = "medium"
aid = request.form.get("assignee_id")
assignee_id = int(aid) if aid and aid.isdigit() else None
max_pos = db_session.exec(select(ProjectTask)
.where(ProjectTask.project_id == project_id)
.where(ProjectTask.status == status)).all()
position = max([t.position for t in max_pos], default=-1) + 1
task = ProjectTask(project_id=project_id, title=title,
description=(request.form.get("description") or "").strip() or None,
status=status, priority=priority, assignee_id=assignee_id,
created_by=session.get("user_id"),
due_date=(request.form.get("due_date") or "").strip() or None,
position=position)
db_session.add(task); db_session.commit()
if assignee_id and assignee_id != session.get("user_id"):
notify_user(assignee_id, f"Задача: {title}", f"Проект: {proj.title}",
link_url=url_for("project_board", project_id=project_id),
kind="info")
return redirect(url_for("project_board", project_id=project_id))
@app.route("/projects/<int:project_id>/tasks/<int:task_id>/edit", methods=["GET", "POST"])
@login_required
def task_edit(project_id, task_id):
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = db_session.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == session.get("user_id"))).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if not perms["can_edit"]: return "403", 403
task = db_session.get(ProjectTask, task_id)
if not task or task.project_id != project_id: return "Не найдено", 404
if request.method == "POST":
old_aid = task.assignee_id
task.title = (request.form.get("title") or task.title).strip()
task.description = (request.form.get("description") or "").strip() or None
new_status = request.form.get("status") or task.status
if new_status in TASK_STATUSES: task.status = new_status
new_priority = request.form.get("priority") or task.priority
if new_priority in TASK_PRIORITIES: task.priority = new_priority
aid = request.form.get("assignee_id")
new_aid = int(aid) if aid and aid.isdigit() else None
task.assignee_id = new_aid
task.due_date = (request.form.get("due_date") or "").strip() or None
task.updated_at = datetime.now(timezone.utc)
db_session.add(task); db_session.commit()
if new_aid and new_aid != old_aid and new_aid != session.get("user_id"):
notify_user(new_aid, f"Задача: {task.title}", f"Проект: {proj.title}",
link_url=url_for("project_board", project_id=project_id),
kind="info")
return redirect(url_for("project_board", project_id=project_id))
members = db_session.exec(
select(ProjectMember, User).join(User, ProjectMember.user_id == User.id)
.where(ProjectMember.project_id == project_id).order_by(User.username)).all()
return render_template("task_edit.html", project=proj, task=task, members=members,
TASK_STATUSES=TASK_STATUSES,
TASK_STATUS_LABELS=TASK_STATUS_LABELS,
TASK_PRIORITIES=TASK_PRIORITIES)
@app.route("/projects/<int:project_id>/tasks/<int:task_id>/delete", methods=["POST"])
@login_required
def task_delete(project_id, task_id):
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = db_session.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == session.get("user_id"))).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if not perms["can_manage_members"] and not perms["is_admin"] and not perms["is_owner"]:
return "403", 403
task = db_session.get(ProjectTask, task_id)
if task and task.project_id == project_id:
db_session.delete(task); db_session.commit()
return redirect(url_for("project_board", project_id=project_id))
@app.route("/projects/<int:project_id>/tasks/<int:task_id>/move", methods=["POST"])
@login_required
def task_move(project_id, task_id):
data = request.get_json(silent=True) or {}
new_status = data.get("status")
new_position = data.get("position", 0)
if new_status not in TASK_STATUSES:
return jsonify({"error": "invalid status"}), 400
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return jsonify({"error": "not found"}), 404
membership = db_session.exec(select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.where(ProjectMember.user_id == session.get("user_id"))).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if not perms["can_edit"]: return jsonify({"error": "no permission"}), 403
task = db_session.get(ProjectTask, task_id)
if not task or task.project_id != project_id:
return jsonify({"error": "not found"}), 404
old_status = task.status
siblings = db_session.exec(
select(ProjectTask).where(ProjectTask.project_id == project_id)
.where(ProjectTask.status == new_status)
.where(ProjectTask.id != task_id)
.order_by(ProjectTask.position, ProjectTask.id)).all()
siblings.insert(max(0, min(int(new_position), len(siblings))), task)
for i, t in enumerate(siblings):
t.position = i
if t.id == task_id:
t.status = new_status
t.updated_at = datetime.now(timezone.utc)
db_session.add(t)
db_session.flush()
if old_status != new_status:
olds = db_session.exec(
select(ProjectTask).where(ProjectTask.project_id == project_id)
.where(ProjectTask.status == old_status)
.order_by(ProjectTask.position, ProjectTask.id)).all()
for i, t in enumerate(olds):
t.position = i; db_session.add(t)
db_session.commit()
return jsonify({"ok": True})