forked from retoor/devplacepy
feat: add file attachment support to issue tracker with Gitea mirroring
Implement full attachment CRUD for issues and comments, restricted to open issues only. Attachments are stored locally and mirrored to the Gitea tracker via new `mirror_attachment_to_gitea` and `set_gitea_asset_id` functions. Add `gitea_asset_id` column to the attachments table, extend `IssueForm` and `IssueCommentForm` with `attachment_uids` field, and expose new API endpoints for listing, adding, and deleting issue attachments. Update agent configuration to include web research tools, and document the new capability in README, AGENTS.md, and CLAUDE.md.
This commit is contained in:
@@ -313,6 +313,7 @@ def store_attachment(file_bytes, original_filename, user_uid):
|
||||
"image_height": image_height,
|
||||
"has_thumbnail": 1 if thumbnail else 0,
|
||||
"thumbnail_name": thumbnail,
|
||||
"gitea_asset_id": None,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
@@ -451,6 +452,69 @@ def link_attachments(uids, target_type, target_uid):
|
||||
)
|
||||
|
||||
|
||||
def set_gitea_asset_id(uid, asset_id):
|
||||
get_table("attachments").update(
|
||||
{"uid": uid, "gitea_asset_id": int(asset_id)}, ["uid"]
|
||||
)
|
||||
|
||||
|
||||
async def mirror_attachment_to_gitea(uid):
|
||||
from devplacepy.services.gitea import runtime
|
||||
from devplacepy.services.gitea.client import GiteaError
|
||||
|
||||
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
|
||||
if not row:
|
||||
return None
|
||||
target_type = row.get("target_type", "")
|
||||
target_uid = row.get("target_uid", "")
|
||||
if target_type not in ("issue", "issue_comment") or not target_uid:
|
||||
return None
|
||||
path = ATTACHMENTS_DIR / row.get("directory", "") / row.get("stored_name", "")
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError as exc:
|
||||
logger.warning("Cannot read attachment %s for Gitea mirror: %s", uid, exc)
|
||||
return None
|
||||
filename = row.get("original_filename") or row.get("stored_name") or "file"
|
||||
mime = row.get("mime_type") or "application/octet-stream"
|
||||
client = runtime.get_client()
|
||||
try:
|
||||
if target_type == "issue":
|
||||
asset = await client.create_issue_asset(
|
||||
int(target_uid), filename, data, mime
|
||||
)
|
||||
else:
|
||||
asset = await client.create_comment_asset(
|
||||
int(target_uid), filename, data, mime
|
||||
)
|
||||
except (GiteaError, ValueError) as exc:
|
||||
logger.warning("Gitea asset mirror failed for %s: %s", uid, exc)
|
||||
return None
|
||||
asset_id = int(asset.get("id", 0) or 0)
|
||||
if asset_id:
|
||||
set_gitea_asset_id(uid, asset_id)
|
||||
return asset_id
|
||||
|
||||
|
||||
async def remove_gitea_asset(row):
|
||||
from devplacepy.services.gitea import runtime
|
||||
from devplacepy.services.gitea.client import GiteaError
|
||||
|
||||
asset_id = int(row.get("gitea_asset_id") or 0)
|
||||
target_type = row.get("target_type", "")
|
||||
target_uid = row.get("target_uid", "")
|
||||
if not asset_id or not target_uid:
|
||||
return
|
||||
client = runtime.get_client()
|
||||
try:
|
||||
if target_type == "issue":
|
||||
await client.delete_issue_asset(int(target_uid), asset_id)
|
||||
elif target_type == "issue_comment":
|
||||
await client.delete_comment_asset(int(target_uid), asset_id)
|
||||
except (GiteaError, ValueError) as exc:
|
||||
logger.warning("Gitea asset delete failed for %s: %s", row.get("uid"), exc)
|
||||
|
||||
|
||||
def _unlink_attachment_files(row):
|
||||
stored_name = row.get("stored_name", "")
|
||||
directory = row.get("directory", "")
|
||||
@@ -618,6 +682,8 @@ def _row_to_attachment(row):
|
||||
"is_audio": row.get("mime_type", "").startswith("audio/"),
|
||||
"target_type": row.get("target_type", ""),
|
||||
"target_uid": row.get("target_uid", ""),
|
||||
"user_uid": row.get("user_uid", ""),
|
||||
"gitea_asset_id": row.get("gitea_asset_id") or None,
|
||||
"created_at": row.get("created_at", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -554,6 +554,7 @@ def init_db():
|
||||
("image_height", 0),
|
||||
("has_thumbnail", 0),
|
||||
("thumbnail_name", ""),
|
||||
("gitea_asset_id", 0),
|
||||
("created_at", ""),
|
||||
("deleted_at", ""),
|
||||
):
|
||||
|
||||
@@ -4074,6 +4074,103 @@ four ways to sign requests.
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="issues-attachments-list",
|
||||
method="GET",
|
||||
path="/issues/{number}/attachments",
|
||||
title="List issue attachments",
|
||||
summary="Return the files attached to an issue ticket.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Issue number."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="issues-attachments-add",
|
||||
method="POST",
|
||||
path="/issues/{number}/attachments",
|
||||
title="Attach files to an issue",
|
||||
summary=(
|
||||
"Link already-uploaded files (from /uploads/upload) to an open issue. The "
|
||||
"files are also mirrored to the Gitea tracker. Allowed only while the issue is open."
|
||||
),
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Issue number."),
|
||||
field(
|
||||
"attachment_uids",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"a1b2c3d4,e5f6g7h8",
|
||||
"Comma separated attachment uids returned by /uploads/upload.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"Only the open issue accepts changes; a closed issue returns 409.",
|
||||
"You can only link your own uploads unless you are an administrator.",
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="issues-attachments-delete",
|
||||
method="DELETE",
|
||||
path="/issues/{number}/attachments/{uid}",
|
||||
title="Delete an issue attachment",
|
||||
summary=(
|
||||
"Soft-delete a file from an open issue (owner or administrator) and remove it "
|
||||
"from the tracker."
|
||||
),
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Issue number."),
|
||||
field("uid", "path", "string", True, "a1b2c3d4", "Attachment uid."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="issues-comment-attachments-add",
|
||||
method="POST",
|
||||
path="/issues/{number}/comments/{cid}/attachments",
|
||||
title="Attach files to an issue comment",
|
||||
summary=(
|
||||
"Link already-uploaded files to an issue comment (issue must be open). Mirrored "
|
||||
"to the Gitea comment."
|
||||
),
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Issue number."),
|
||||
field("cid", "path", "integer", True, "1", "Gitea comment id."),
|
||||
field(
|
||||
"attachment_uids",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"a1b2c3d4",
|
||||
"Comma separated attachment uids returned by /uploads/upload.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="issues-comment-attachments-delete",
|
||||
method="DELETE",
|
||||
path="/issues/{number}/comments/{cid}/attachments/{uid}",
|
||||
title="Delete an issue comment attachment",
|
||||
summary=(
|
||||
"Soft-delete a file from an issue comment (owner or administrator) and remove it "
|
||||
"from the tracker."
|
||||
),
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Issue number."),
|
||||
field("cid", "path", "integer", True, "1", "Gitea comment id."),
|
||||
field("uid", "path", "string", True, "a1b2c3d4", "Attachment uid."),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -5033,6 +5130,7 @@ _PAGE_RESPONSES = {
|
||||
"notifications-list": schemas.NotificationsOut,
|
||||
"issues-list": schemas.IssuesOut,
|
||||
"issues-detail": schemas.IssueDetailOut,
|
||||
"issues-attachments-list": schemas.IssueAttachmentsOut,
|
||||
"bookmarks-saved": schemas.SavedOut,
|
||||
"admin-users": schemas.AdminUsersOut,
|
||||
"admin-news-list": schemas.AdminNewsOut,
|
||||
|
||||
@@ -400,10 +400,16 @@ class GistEditForm(BaseModel):
|
||||
class IssueForm(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
description: str = Field(min_length=1, max_length=5000)
|
||||
attachment_uids: list[str] = []
|
||||
|
||||
|
||||
class IssueCommentForm(BaseModel):
|
||||
body: str = Field(min_length=1, max_length=5000)
|
||||
attachment_uids: list[str] = []
|
||||
|
||||
|
||||
class IssueAttachmentForm(BaseModel):
|
||||
attachment_uids: list[str] = []
|
||||
|
||||
|
||||
class IssueStatusForm(BaseModel):
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.routers.issues import comment, create, planning, status
|
||||
from devplacepy.routers.issues import attachments, comment, create, planning, status
|
||||
from devplacepy.routers.issues.index import router
|
||||
|
||||
router.include_router(planning.router)
|
||||
router.include_router(create.router)
|
||||
router.include_router(comment.router)
|
||||
router.include_router(status.router)
|
||||
router.include_router(attachments.router)
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.attachments import (
|
||||
get_attachments,
|
||||
link_attachments,
|
||||
mirror_attachment_to_gitea,
|
||||
remove_gitea_asset,
|
||||
soft_delete_attachment,
|
||||
)
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import IssueAttachmentForm
|
||||
from devplacepy.responses import action_result, json_error
|
||||
from devplacepy.schemas import IssueAttachmentsOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.gitea import runtime
|
||||
from devplacepy.services.gitea.client import STATE_OPEN, GiteaError
|
||||
from devplacepy.services.gitea.config import gitea_config
|
||||
from devplacepy.utils import get_current_user, is_admin, not_found, require_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _split_uids(raw) -> list[str]:
|
||||
return [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()]
|
||||
|
||||
|
||||
def _can_modify(att: dict, user: dict | None, is_open: bool) -> bool:
|
||||
if not user or not is_open:
|
||||
return False
|
||||
return att.get("user_uid") == user["uid"] or is_admin(user)
|
||||
|
||||
|
||||
def _payload(rows: list[dict], user: dict | None, is_open: bool) -> list[dict]:
|
||||
items = []
|
||||
for row in rows:
|
||||
att = dict(row)
|
||||
att["can_modify"] = _can_modify(att, user, is_open)
|
||||
items.append(att)
|
||||
return items
|
||||
|
||||
|
||||
def _claim_orphans(uids: list[str], user: dict) -> list[str]:
|
||||
admin = is_admin(user)
|
||||
owned = []
|
||||
for uid in uids:
|
||||
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
|
||||
if not row:
|
||||
continue
|
||||
if row.get("user_uid") and row["user_uid"] != user["uid"] and not admin:
|
||||
continue
|
||||
if row.get("target_uid"):
|
||||
continue
|
||||
owned.append(uid)
|
||||
return owned
|
||||
|
||||
|
||||
async def _load_issue(number: int) -> dict:
|
||||
try:
|
||||
return await runtime.get_client().get_issue(number)
|
||||
except GiteaError as exc:
|
||||
if exc.status == 404:
|
||||
raise not_found("Issue not found")
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/{number}/attachments")
|
||||
async def list_issue_attachments(request: Request, number: int):
|
||||
if not gitea_config().is_configured:
|
||||
return json_error(503, "The issue tracker is not configured")
|
||||
user = get_current_user(request)
|
||||
issue = await _load_issue(number)
|
||||
is_open = issue.get("state") == STATE_OPEN
|
||||
rows = get_attachments("issue", str(number))
|
||||
payload = {"number": number, "attachments": _payload(rows, user, is_open)}
|
||||
return JSONResponse(
|
||||
IssueAttachmentsOut.model_validate(payload).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{number}/attachments")
|
||||
async def add_issue_attachment(
|
||||
request: Request,
|
||||
number: int,
|
||||
data: Annotated[IssueAttachmentForm, Depends(json_or_form(IssueAttachmentForm))],
|
||||
):
|
||||
user = require_user(request)
|
||||
if not gitea_config().is_configured:
|
||||
return json_error(503, "The issue tracker is not configured")
|
||||
issue = await _load_issue(number)
|
||||
if issue.get("state") != STATE_OPEN:
|
||||
audit.record(
|
||||
request,
|
||||
"issue.attachment.add",
|
||||
user=user,
|
||||
result="denied",
|
||||
target_type="issue",
|
||||
target_uid=str(number),
|
||||
summary=f"{user['username']} tried to attach to closed issue #{number}",
|
||||
)
|
||||
return json_error(409, "Attachments cannot be changed on a closed issue")
|
||||
owned = _claim_orphans(_split_uids(data.attachment_uids), user)
|
||||
if not owned:
|
||||
return json_error(400, "No valid attachments to add")
|
||||
link_attachments(owned, "issue", str(number))
|
||||
for uid in owned:
|
||||
await mirror_attachment_to_gitea(uid)
|
||||
audit.record(
|
||||
request,
|
||||
"issue.attachment.add",
|
||||
user=user,
|
||||
target_type="issue",
|
||||
target_uid=str(number),
|
||||
summary=f"{user['username']} attached {len(owned)} file(s) to issue #{number}",
|
||||
metadata={"number": number, "count": len(owned)},
|
||||
links=[audit.target("issue", str(number))],
|
||||
)
|
||||
return action_result(
|
||||
request, f"/issues/{number}", data={"count": len(owned), "uids": owned}
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{number}/attachments/{uid}")
|
||||
async def delete_issue_attachment(request: Request, number: int, uid: str):
|
||||
user = require_user(request)
|
||||
if not gitea_config().is_configured:
|
||||
return json_error(503, "The issue tracker is not configured")
|
||||
issue = await _load_issue(number)
|
||||
att = get_table("attachments").find_one(
|
||||
uid=uid, target_type="issue", target_uid=str(number), deleted_at=None
|
||||
)
|
||||
if not att:
|
||||
raise not_found("Attachment not found")
|
||||
if att.get("user_uid") and att["user_uid"] != user["uid"] and not is_admin(user):
|
||||
audit.record(
|
||||
request,
|
||||
"issue.attachment.delete",
|
||||
user=user,
|
||||
result="denied",
|
||||
target_type="issue",
|
||||
target_uid=str(number),
|
||||
summary=f"{user['username']} tried to delete another user's attachment on issue #{number}",
|
||||
)
|
||||
return json_error(403, "Not authorized")
|
||||
if issue.get("state") != STATE_OPEN:
|
||||
audit.record(
|
||||
request,
|
||||
"issue.attachment.delete",
|
||||
user=user,
|
||||
result="denied",
|
||||
target_type="issue",
|
||||
target_uid=str(number),
|
||||
summary=f"{user['username']} tried to delete an attachment on closed issue #{number}",
|
||||
)
|
||||
return json_error(409, "Attachments cannot be changed on a closed issue")
|
||||
soft_delete_attachment(uid, deleted_by=user["uid"])
|
||||
await remove_gitea_asset(att)
|
||||
audit.record(
|
||||
request,
|
||||
"issue.attachment.delete",
|
||||
user=user,
|
||||
target_type="issue",
|
||||
target_uid=str(number),
|
||||
summary=f"{user['username']} deleted an attachment on issue #{number}",
|
||||
metadata={"number": number, "uid": uid},
|
||||
links=[audit.target("issue", str(number))],
|
||||
)
|
||||
return action_result(request, f"/issues/{number}", data={"status": "deleted"})
|
||||
|
||||
|
||||
@router.get("/{number}/comments/{cid}/attachments")
|
||||
async def list_comment_attachments(request: Request, number: int, cid: int):
|
||||
if not gitea_config().is_configured:
|
||||
return json_error(503, "The issue tracker is not configured")
|
||||
user = get_current_user(request)
|
||||
issue = await _load_issue(number)
|
||||
is_open = issue.get("state") == STATE_OPEN
|
||||
rows = get_attachments("issue_comment", str(cid))
|
||||
payload = {"number": number, "attachments": _payload(rows, user, is_open)}
|
||||
return JSONResponse(
|
||||
IssueAttachmentsOut.model_validate(payload).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{number}/comments/{cid}/attachments")
|
||||
async def add_comment_attachment(
|
||||
request: Request,
|
||||
number: int,
|
||||
cid: int,
|
||||
data: Annotated[IssueAttachmentForm, Depends(json_or_form(IssueAttachmentForm))],
|
||||
):
|
||||
user = require_user(request)
|
||||
if not gitea_config().is_configured:
|
||||
return json_error(503, "The issue tracker is not configured")
|
||||
issue = await _load_issue(number)
|
||||
if issue.get("state") != STATE_OPEN:
|
||||
return json_error(409, "Attachments cannot be changed on a closed issue")
|
||||
owned = _claim_orphans(_split_uids(data.attachment_uids), user)
|
||||
if not owned:
|
||||
return json_error(400, "No valid attachments to add")
|
||||
link_attachments(owned, "issue_comment", str(cid))
|
||||
for uid in owned:
|
||||
await mirror_attachment_to_gitea(uid)
|
||||
audit.record(
|
||||
request,
|
||||
"issue.attachment.add",
|
||||
user=user,
|
||||
target_type="issue",
|
||||
target_uid=str(number),
|
||||
summary=f"{user['username']} attached {len(owned)} file(s) to a comment on issue #{number}",
|
||||
metadata={"number": number, "comment_id": cid, "count": len(owned)},
|
||||
links=[audit.target("issue", str(number))],
|
||||
)
|
||||
return action_result(
|
||||
request, f"/issues/{number}", data={"count": len(owned), "uids": owned}
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{number}/comments/{cid}/attachments/{uid}")
|
||||
async def delete_comment_attachment(
|
||||
request: Request, number: int, cid: int, uid: str
|
||||
):
|
||||
user = require_user(request)
|
||||
if not gitea_config().is_configured:
|
||||
return json_error(503, "The issue tracker is not configured")
|
||||
issue = await _load_issue(number)
|
||||
att = get_table("attachments").find_one(
|
||||
uid=uid, target_type="issue_comment", target_uid=str(cid), deleted_at=None
|
||||
)
|
||||
if not att:
|
||||
raise not_found("Attachment not found")
|
||||
if att.get("user_uid") and att["user_uid"] != user["uid"] and not is_admin(user):
|
||||
audit.record(
|
||||
request,
|
||||
"issue.attachment.delete",
|
||||
user=user,
|
||||
result="denied",
|
||||
target_type="issue",
|
||||
target_uid=str(number),
|
||||
summary=f"{user['username']} tried to delete another user's comment attachment on issue #{number}",
|
||||
)
|
||||
return json_error(403, "Not authorized")
|
||||
if issue.get("state") != STATE_OPEN:
|
||||
return json_error(409, "Attachments cannot be changed on a closed issue")
|
||||
soft_delete_attachment(uid, deleted_by=user["uid"])
|
||||
await remove_gitea_asset(att)
|
||||
audit.record(
|
||||
request,
|
||||
"issue.attachment.delete",
|
||||
user=user,
|
||||
target_type="issue",
|
||||
target_uid=str(number),
|
||||
summary=f"{user['username']} deleted a comment attachment on issue #{number}",
|
||||
metadata={"number": number, "comment_id": cid, "uid": uid},
|
||||
links=[audit.target("issue", str(number))],
|
||||
)
|
||||
return action_result(request, f"/issues/{number}", data={"status": "deleted"})
|
||||
@@ -5,6 +5,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
|
||||
from devplacepy.attachments import link_attachments, mirror_attachment_to_gitea
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.models import IssueCommentForm
|
||||
from devplacepy.responses import action_result, json_error
|
||||
@@ -19,6 +20,19 @@ from devplacepy.dependencies import json_or_form
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _owned_orphan_uids(raw: list[str], user: dict) -> list[str]:
|
||||
flat = [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()]
|
||||
owned = []
|
||||
for uid in flat:
|
||||
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
|
||||
if not row or row.get("target_uid"):
|
||||
continue
|
||||
if row.get("user_uid") and row["user_uid"] != user["uid"]:
|
||||
continue
|
||||
owned.append(uid)
|
||||
return owned
|
||||
|
||||
def _notify_admins(actor: dict, number: int) -> None:
|
||||
for admin in get_table("users").find(role="Admin"):
|
||||
if admin["uid"] == actor["uid"]:
|
||||
@@ -60,6 +74,11 @@ async def comment_issue(
|
||||
|
||||
comment_id = int(comment.get("id", 0))
|
||||
store.record_comment_author(comment_id, number, user["uid"])
|
||||
owned = _owned_orphan_uids(data.attachment_uids, user)
|
||||
if owned:
|
||||
link_attachments(owned, "issue_comment", str(comment_id))
|
||||
for uid in owned:
|
||||
await mirror_attachment_to_gitea(uid)
|
||||
background.submit(_notify_admins, user, number)
|
||||
audit.record(
|
||||
request,
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Annotated
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.models import IssueForm
|
||||
from devplacepy.responses import json_error
|
||||
from devplacepy.schemas import IssueJobOut
|
||||
@@ -18,6 +19,20 @@ from devplacepy.dependencies import json_or_form
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _owned_orphan_uids(raw: list[str], user: dict) -> list[str]:
|
||||
flat = [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()]
|
||||
owned = []
|
||||
for uid in flat:
|
||||
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
|
||||
if not row or row.get("target_uid"):
|
||||
continue
|
||||
if row.get("user_uid") and row["user_uid"] != user["uid"]:
|
||||
continue
|
||||
owned.append(uid)
|
||||
return owned
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json_or_form(IssueForm))]):
|
||||
user = require_user(request)
|
||||
@@ -30,6 +45,7 @@ async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json
|
||||
"author_uid": user["uid"],
|
||||
"title": title,
|
||||
"description": data.description.strip(),
|
||||
"attachment_uids": _owned_orphan_uids(data.attachment_uids, user),
|
||||
},
|
||||
"user",
|
||||
user["uid"],
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from devplacepy.attachments import get_attachments, get_attachments_batch
|
||||
from devplacepy.database import build_pagination, get_users_by_uids, get_blocked_uids
|
||||
from devplacepy.responses import respond
|
||||
from devplacepy.schemas import IssueDetailOut, IssuesOut
|
||||
@@ -109,12 +110,33 @@ async def issue_detail(request: Request, number: int):
|
||||
|
||||
body = issue.get("body", "")
|
||||
issue = issue_item(issue, author_uid, users_map)
|
||||
comment_items = [
|
||||
comment_item(
|
||||
is_open = issue.get("state") == STATE_OPEN
|
||||
|
||||
def _decorate(rows):
|
||||
items = []
|
||||
for row in rows:
|
||||
att = dict(row)
|
||||
att["can_modify"] = bool(
|
||||
user
|
||||
and is_open
|
||||
and (att.get("user_uid") == user["uid"] or is_admin(user))
|
||||
)
|
||||
items.append(att)
|
||||
return items
|
||||
|
||||
issue_attachments = _decorate(get_attachments("issue", str(number)))
|
||||
comment_attachments = get_attachments_batch(
|
||||
"issue_comment", [str(int(comment.get("id", 0))) for comment in comments]
|
||||
)
|
||||
comment_items = []
|
||||
for comment in comments:
|
||||
item = comment_item(
|
||||
comment, comment_authors.get(int(comment.get("id", 0))), users_map
|
||||
)
|
||||
for comment in comments
|
||||
]
|
||||
item["attachments"] = _decorate(
|
||||
comment_attachments.get(str(item["id"]), [])
|
||||
)
|
||||
comment_items.append(item)
|
||||
seo_ctx = issue_seo(
|
||||
request,
|
||||
title=f"Issue #{number}: {issue.get('title', '')}",
|
||||
@@ -130,7 +152,9 @@ async def issue_detail(request: Request, number: int):
|
||||
"issue": issue,
|
||||
"body": body,
|
||||
"comments": comment_items,
|
||||
"attachments": issue_attachments,
|
||||
"can_comment": user is not None,
|
||||
"can_attach": user is not None and is_open,
|
||||
"viewer_is_admin": is_admin(user),
|
||||
},
|
||||
model=IssueDetailOut,
|
||||
|
||||
@@ -293,6 +293,7 @@ class AttachmentOut(_Out):
|
||||
is_video: Optional[bool] = None
|
||||
mime_type: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
can_modify: bool = False
|
||||
|
||||
|
||||
class VotesOut(_Out):
|
||||
@@ -549,16 +550,24 @@ class IssueCommentOut(_Out):
|
||||
author_uid: Optional[str] = None
|
||||
author_avatar_seed: Optional[str] = None
|
||||
is_local_author: bool = False
|
||||
attachments: list[AttachmentOut] = []
|
||||
|
||||
|
||||
class IssueDetailOut(_Out):
|
||||
issue: IssueItemOut
|
||||
body: str = ""
|
||||
comments: list[IssueCommentOut] = []
|
||||
attachments: list[AttachmentOut] = []
|
||||
can_comment: bool = False
|
||||
can_attach: bool = False
|
||||
viewer_is_admin: bool = False
|
||||
|
||||
|
||||
class IssueAttachmentsOut(_Out):
|
||||
number: int = 0
|
||||
attachments: list[AttachmentOut] = []
|
||||
|
||||
|
||||
class IssueJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
|
||||
@@ -1016,6 +1016,62 @@ ACTIONS: tuple[Action, ...] = (
|
||||
body("status", "New status: open or closed.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="list_issue_attachments",
|
||||
method="GET",
|
||||
path="/issues/{number}/attachments",
|
||||
summary="List the files attached to an issue ticket",
|
||||
requires_auth=False,
|
||||
params=(path("number", "The issue ticket number."),),
|
||||
),
|
||||
Action(
|
||||
name="add_issue_attachment",
|
||||
method="POST",
|
||||
path="/issues/{number}/attachments",
|
||||
summary="Attach already-uploaded files to an open issue ticket",
|
||||
description=(
|
||||
"Upload files first with upload_file, then pass their uids here. Only works while "
|
||||
"the issue is open; the files are mirrored to the tracker."
|
||||
),
|
||||
params=(
|
||||
path("number", "The issue ticket number."),
|
||||
body("attachment_uids", ATTACHMENTS, required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="delete_issue_attachment",
|
||||
method="DELETE",
|
||||
path="/issues/{number}/attachments/{uid}",
|
||||
summary="Delete a file from an open issue ticket (your own, or any when administrator). Soft delete, confirmation required",
|
||||
params=(
|
||||
path("number", "The issue ticket number."),
|
||||
path("uid", "The attachment uid."),
|
||||
confirm(),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="add_comment_attachment",
|
||||
method="POST",
|
||||
path="/issues/{number}/comments/{cid}/attachments",
|
||||
summary="Attach already-uploaded files to an issue comment (issue must be open)",
|
||||
params=(
|
||||
path("number", "The issue ticket number."),
|
||||
path("cid", "The Gitea comment id."),
|
||||
body("attachment_uids", ATTACHMENTS, required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="delete_comment_attachment",
|
||||
method="DELETE",
|
||||
path="/issues/{number}/comments/{cid}/attachments/{uid}",
|
||||
summary="Delete a file from an issue comment (your own, or any when administrator). Soft delete, confirmation required",
|
||||
params=(
|
||||
path("number", "The issue ticket number."),
|
||||
path("cid", "The Gitea comment id."),
|
||||
path("uid", "The attachment uid."),
|
||||
confirm(),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="list_gists",
|
||||
method="GET",
|
||||
|
||||
@@ -45,6 +45,8 @@ CONFIRM_REQUIRED = {
|
||||
"delete_comment",
|
||||
"delete_gist",
|
||||
"delete_attachment",
|
||||
"delete_issue_attachment",
|
||||
"delete_comment_attachment",
|
||||
"admin_media_purge",
|
||||
"admin_delete_news",
|
||||
"admin_reset_all_ai_quota",
|
||||
|
||||
@@ -31,24 +31,28 @@ class GiteaClient:
|
||||
def __init__(self, config: GiteaConfig | None = None):
|
||||
self.config = config or gitea_config()
|
||||
|
||||
def _headers(self) -> dict:
|
||||
return {
|
||||
def _headers(self, json_body: bool = True) -> dict:
|
||||
headers = {
|
||||
"Authorization": f"token {self.config.token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if json_body:
|
||||
headers["Content-Type"] = "application/json"
|
||||
return headers
|
||||
|
||||
def _require_config(self) -> None:
|
||||
if not self.config.is_configured:
|
||||
raise GiteaError("Gitea integration is not configured", status=503)
|
||||
|
||||
async def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
|
||||
async def _request(
|
||||
self, method: str, url: str, json_body: bool = True, **kwargs
|
||||
) -> httpx.Response:
|
||||
self._require_config()
|
||||
logger.debug("Gitea %s %s", method, url)
|
||||
try:
|
||||
async with stealth.stealth_async_client(timeout=HTTP_TIMEOUT_SECONDS) as client:
|
||||
response = await client.request(
|
||||
method, url, headers=self._headers(), **kwargs
|
||||
method, url, headers=self._headers(json_body), **kwargs
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("Gitea request failed: %s", exc)
|
||||
@@ -123,6 +127,52 @@ class GiteaClient:
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def list_issue_assets(self, number: int) -> list[dict]:
|
||||
response = await self._request(
|
||||
"GET", f"{self.config.repo_base}/issues/{number}/assets"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def create_issue_asset(
|
||||
self, number: int, filename: str, data: bytes, mime: str
|
||||
) -> dict:
|
||||
response = await self._request(
|
||||
"POST",
|
||||
f"{self.config.repo_base}/issues/{number}/assets",
|
||||
json_body=False,
|
||||
files={"attachment": (filename, data, mime or "application/octet-stream")},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def delete_issue_asset(self, number: int, attachment_id: int) -> None:
|
||||
await self._request(
|
||||
"DELETE",
|
||||
f"{self.config.repo_base}/issues/{number}/assets/{attachment_id}",
|
||||
)
|
||||
|
||||
async def list_comment_assets(self, comment_id: int) -> list[dict]:
|
||||
response = await self._request(
|
||||
"GET", f"{self.config.repo_base}/issues/comments/{comment_id}/assets"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def create_comment_asset(
|
||||
self, comment_id: int, filename: str, data: bytes, mime: str
|
||||
) -> dict:
|
||||
response = await self._request(
|
||||
"POST",
|
||||
f"{self.config.repo_base}/issues/comments/{comment_id}/assets",
|
||||
json_body=False,
|
||||
files={"attachment": (filename, data, mime or "application/octet-stream")},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def delete_comment_asset(self, comment_id: int, attachment_id: int) -> None:
|
||||
await self._request(
|
||||
"DELETE",
|
||||
f"{self.config.repo_base}/issues/comments/{comment_id}/assets/{attachment_id}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _total_count(response: httpx.Response, fallback: int) -> int:
|
||||
raw = response.headers.get("X-Total-Count", "")
|
||||
|
||||
@@ -19,8 +19,11 @@ class FakeGiteaClient:
|
||||
self.bot_login = bot_login
|
||||
self._issues: dict[int, dict] = {}
|
||||
self._comments: dict[int, list[dict]] = {}
|
||||
self._issue_assets: dict[int, list[dict]] = {}
|
||||
self._comment_assets: dict[int, list[dict]] = {}
|
||||
self._issue_seq = 0
|
||||
self._comment_seq = 0
|
||||
self._asset_seq = 0
|
||||
|
||||
def _html(self, number: int) -> str:
|
||||
return f"https://gitea.test/retoor/pydevplace/issues/{number}"
|
||||
@@ -97,6 +100,52 @@ class FakeGiteaClient:
|
||||
issue["updated_at"] = _now()
|
||||
return dict(issue)
|
||||
|
||||
def _asset(self, filename: str, data: bytes) -> dict:
|
||||
self._asset_seq += 1
|
||||
return {
|
||||
"id": self._asset_seq,
|
||||
"name": filename,
|
||||
"size": len(data),
|
||||
"download_count": 0,
|
||||
"uuid": f"asset-{self._asset_seq}",
|
||||
"browser_download_url": f"https://gitea.test/attachments/asset-{self._asset_seq}",
|
||||
"created_at": _now(),
|
||||
}
|
||||
|
||||
async def list_issue_assets(self, number: int) -> list[dict]:
|
||||
if number not in self._issues:
|
||||
raise GiteaError("issue not found", status=404)
|
||||
return [dict(item) for item in self._issue_assets.get(number, [])]
|
||||
|
||||
async def create_issue_asset(
|
||||
self, number: int, filename: str, data: bytes, mime: str
|
||||
) -> dict:
|
||||
if number not in self._issues:
|
||||
raise GiteaError("issue not found", status=404)
|
||||
asset = self._asset(filename, data)
|
||||
self._issue_assets.setdefault(number, []).append(asset)
|
||||
return dict(asset)
|
||||
|
||||
async def delete_issue_asset(self, number: int, attachment_id: int) -> None:
|
||||
assets = self._issue_assets.get(number, [])
|
||||
self._issue_assets[number] = [a for a in assets if a["id"] != attachment_id]
|
||||
|
||||
async def list_comment_assets(self, comment_id: int) -> list[dict]:
|
||||
return [dict(item) for item in self._comment_assets.get(comment_id, [])]
|
||||
|
||||
async def create_comment_asset(
|
||||
self, comment_id: int, filename: str, data: bytes, mime: str
|
||||
) -> dict:
|
||||
asset = self._asset(filename, data)
|
||||
self._comment_assets.setdefault(comment_id, []).append(asset)
|
||||
return dict(asset)
|
||||
|
||||
async def delete_comment_asset(self, comment_id: int, attachment_id: int) -> None:
|
||||
assets = self._comment_assets.get(comment_id, [])
|
||||
self._comment_assets[comment_id] = [
|
||||
a for a in assets if a["id"] != attachment_id
|
||||
]
|
||||
|
||||
def add_external_comment(self, number: int, login: str, body: str) -> dict:
|
||||
self._comment_seq += 1
|
||||
now = _now()
|
||||
|
||||
@@ -66,6 +66,17 @@ class IssueCreateService(JobService):
|
||||
status=issue.get("state", "open"),
|
||||
)
|
||||
|
||||
attachment_uids = payload.get("attachment_uids") or []
|
||||
if attachment_uids:
|
||||
from devplacepy.attachments import (
|
||||
link_attachments,
|
||||
mirror_attachment_to_gitea,
|
||||
)
|
||||
|
||||
link_attachments(attachment_uids, "issue", str(number))
|
||||
for uid in attachment_uids:
|
||||
await mirror_attachment_to_gitea(uid)
|
||||
|
||||
create_notification(
|
||||
author_uid,
|
||||
"issue",
|
||||
|
||||
@@ -217,3 +217,29 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-gallery-item .issue-att-delete {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
.attachment-gallery-item .issue-att-delete:hover {
|
||||
background: var(--danger, #c0392b);
|
||||
}
|
||||
.issue-attach-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { DeviiTerminal } from "./DeviiTerminal.js";
|
||||
import { ZipDownloader } from "./ZipDownloader.js";
|
||||
import { ProjectForker } from "./ProjectForker.js";
|
||||
import { IssueReporter } from "./IssueReporter.js";
|
||||
import { IssueAttachments } from "./IssueAttachments.js";
|
||||
import { PlanningGenerator } from "./PlanningGenerator.js";
|
||||
import { MediaGallery } from "./MediaGallery.js";
|
||||
import WindowManager from "./components/WindowManager.js";
|
||||
@@ -79,6 +80,7 @@ class Application {
|
||||
this.zipDownloader = new ZipDownloader();
|
||||
this.projectForker = new ProjectForker();
|
||||
this.issueReporter = new IssueReporter();
|
||||
this.issueAttachments = new IssueAttachments();
|
||||
this.planningGenerator = new PlanningGenerator();
|
||||
this.mediaGallery = new MediaGallery();
|
||||
this.liveNotifications = new LiveNotifications(this.pubsub, this.toast);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
export class IssueAttachments {
|
||||
constructor() {
|
||||
this.active = false;
|
||||
document.addEventListener("submit", (event) => {
|
||||
const form = event.target.closest("form[data-issue-attach]");
|
||||
if (!form) return;
|
||||
event.preventDefault();
|
||||
this.add(form);
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-attachment-delete]");
|
||||
if (!button) return;
|
||||
event.preventDefault();
|
||||
this.remove(button);
|
||||
});
|
||||
}
|
||||
|
||||
async add(form) {
|
||||
if (this.active) return;
|
||||
const uidsInput = form.querySelector("[name='attachment_uids']");
|
||||
const uids = uidsInput ? uidsInput.value.trim() : "";
|
||||
if (!uids) {
|
||||
window.app.toast.show("Select a file to attach first", { type: "info" });
|
||||
return;
|
||||
}
|
||||
this.active = true;
|
||||
const button = form.querySelector("button[type='submit']");
|
||||
if (button) button.disabled = true;
|
||||
try {
|
||||
await Http.send(form.dataset.action, { attachment_uids: uids });
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
this.active = false;
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(button) {
|
||||
const confirmed = await window.app.dialog.confirm({
|
||||
title: "Delete attachment",
|
||||
message: "Delete this attachment? It is also removed from the tracker.",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const response = await fetch(button.dataset.action, {
|
||||
method: "DELETE",
|
||||
headers: { "Accept": "application/json" },
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.ok === false) {
|
||||
const message = (data.error && data.error.message) || "Could not delete the attachment";
|
||||
throw new Error(message);
|
||||
}
|
||||
const item = button.closest(".attachment-gallery-item");
|
||||
if (item) item.remove();
|
||||
} catch (error) {
|
||||
Http.notifyError(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export class IssueReporter {
|
||||
window.app.toast.show("Title and description are required", { type: "error" });
|
||||
return;
|
||||
}
|
||||
const uidsInput = form.querySelector("[name='attachment_uids']");
|
||||
const attachmentUids = uidsInput ? uidsInput.value : "";
|
||||
this.active = true;
|
||||
const button = form.querySelector("button[type='submit']");
|
||||
if (button) {
|
||||
@@ -33,7 +35,11 @@ export class IssueReporter {
|
||||
ms: 5000,
|
||||
});
|
||||
try {
|
||||
const job = await Http.sendForm(form.action, { title, description });
|
||||
const job = await Http.sendForm(form.action, {
|
||||
title,
|
||||
description,
|
||||
attachment_uids: attachmentUids,
|
||||
});
|
||||
await this.poll(job.status_url);
|
||||
} catch (error) {
|
||||
window.app.toast.show("Could not submit the report", { type: "error" });
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<div class="attachment-gallery">
|
||||
{% for att in _attachments %}
|
||||
<div class="attachment-gallery-item" data-att-uid="{{ att.uid }}">
|
||||
{% if att.get('is_image') and att.get('thumbnail_url') %}
|
||||
<img src="{{ att['thumbnail_url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
|
||||
{% elif att.get('is_image') %}
|
||||
<img src="{{ att['url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
|
||||
{% elif att.get('is_video') %}
|
||||
<video src="{{ att['url'] }}" controls preload="metadata" class="gallery-video"></video>
|
||||
{% elif att.get('is_audio') %}
|
||||
<audio src="{{ att['url'] }}" controls preload="metadata" class="gallery-audio"></audio>
|
||||
{% else %}
|
||||
<a href="{{ att['url'] }}" target="_blank" rel="noopener" class="non-image" download="{{ att.get('original_filename', 'file') }}">
|
||||
<span class="icon">{{ file_icon_emoji(att.get('original_filename', 'file')) }}</span>
|
||||
<span class="name">{{ att.get('original_filename', 'file') }}</span>
|
||||
<span class="size">{{ format_file_size(att.get('file_size', 0)) }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if att.get('can_modify') %}
|
||||
<button type="button" class="issue-att-delete" data-attachment-delete data-action="{{ _delete_base }}/{{ att.uid }}" aria-label="Delete attachment">×</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -41,6 +41,17 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="issue-detail-body rendered-content">{{ render_content(body) }}</div>
|
||||
|
||||
{% if attachments %}
|
||||
{% set _attachments = attachments %}{% set _delete_base = "/issues/" ~ issue.number ~ "/attachments" %}{% include "_issue_attachments.html" %}
|
||||
{% endif %}
|
||||
|
||||
{% if can_attach %}
|
||||
<form class="issue-attach-form" data-issue-attach data-action="/issues/{{ issue.number }}/attachments">
|
||||
{% include "_attachment_form.html" %}
|
||||
<button type="submit" class="btn btn-secondary btn-sm"><span class="btn-spinner" aria-hidden="true"></span>Attach files</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section class="comments-section">
|
||||
@@ -58,6 +69,9 @@
|
||||
<span class="issue-date">{{ local_dt(comment.created_at) }}</span>
|
||||
</div>
|
||||
<div class="issue-comment-body rendered-content">{{ render_content(comment.body) }}</div>
|
||||
{% if comment.attachments %}
|
||||
{% set _attachments = comment.attachments %}{% set _delete_base = "/issues/" ~ issue.number ~ "/comments/" ~ comment.id ~ "/attachments" %}{% include "_issue_attachments.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-comments-msg">No updates yet.</p>
|
||||
@@ -67,6 +81,7 @@
|
||||
<form method="POST" action="/issues/{{ issue.number }}/comment" class="comment-form issue-comment-form">
|
||||
<textarea name="body" required aria-required="true" maxlength="5000" placeholder="Add a comment (posted to the tracker)..." class="min-h-120" data-mention aria-label="Add a comment"></textarea>
|
||||
<div class="issue-comment-form-footer">
|
||||
{% include "_attachment_form.html" %}
|
||||
<button type="submit" class="btn btn-primary btn-sm"><span class="btn-spinner" aria-hidden="true"></span>Comment</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -72,6 +72,10 @@
|
||||
<label for="issue-description">Description</label>
|
||||
<textarea id="issue-description" name="description" required aria-required="true" maxlength="5000" placeholder="Steps to reproduce, expected vs actual, environment..." class="min-h-120" data-mention></textarea>
|
||||
</div>
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label>Attachments</label>
|
||||
{% include "_attachment_form.html" %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary"><span class="btn-spinner" aria-hidden="true"></span><span class="icon">📤</span> Submit Report</button>
|
||||
|
||||
Reference in New Issue
Block a user