"""Роуты проектов: каталог, карточка, участники, лента, файлы, канбан.""" 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/") 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//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//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//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//members//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//members//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//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//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//posts//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//posts//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//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//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//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//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//tasks//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//tasks//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//tasks//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})