issue.py 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537
  1. # -*- coding: utf-8 -*-
  2. """
  3. (c) 2015-2017 - Copyright Red Hat Inc
  4. Authors:
  5. Pierre-Yves Chibon <pingou@pingoured.fr>
  6. """
  7. from __future__ import print_function, unicode_literals, absolute_import
  8. import flask
  9. import datetime
  10. import logging
  11. import arrow
  12. from sqlalchemy.exc import SQLAlchemyError
  13. import pagure.exceptions
  14. import pagure.lib.query
  15. from pagure.api import (
  16. API,
  17. api_method,
  18. api_login_required,
  19. api_login_optional,
  20. APIERROR,
  21. get_request_data,
  22. get_page,
  23. get_per_page,
  24. )
  25. from pagure.config import config as pagure_config
  26. from pagure.utils import (
  27. api_authenticated,
  28. is_repo_committer,
  29. urlpattern,
  30. is_true,
  31. )
  32. from pagure.api.utils import (
  33. _get_repo,
  34. _check_token,
  35. _get_issue,
  36. _check_issue_tracker,
  37. _check_ticket_access,
  38. _check_private_issue_access,
  39. )
  40. _log = logging.getLogger(__name__)
  41. def _check_link_custom_field(field, links):
  42. """Check if the value provided in the link custom field
  43. is a link.
  44. :param field : The issue custom field key object.
  45. :param links : Value of the custom field.
  46. :raises pagure.exceptions.APIERROR when invalid.
  47. """
  48. if field.key_type == "link":
  49. links = links.split(",")
  50. for link in links:
  51. link = link.replace(" ", "")
  52. if not urlpattern.match(link):
  53. raise pagure.exceptions.APIError(
  54. 400, error_code=APIERROR.EINVALIDISSUEFIELD_LINK
  55. )
  56. @API.route("/<repo>/new_issue", methods=["POST"])
  57. @API.route("/<namespace>/<repo>/new_issue", methods=["POST"])
  58. @API.route("/fork/<username>/<repo>/new_issue", methods=["POST"])
  59. @API.route("/fork/<username>/<namespace>/<repo>/new_issue", methods=["POST"])
  60. @api_login_required(acls=["issue_create"])
  61. @api_method
  62. def api_new_issue(repo, username=None, namespace=None):
  63. """
  64. Create a new issue
  65. ------------------
  66. Open a new issue on a project.
  67. ::
  68. POST /api/0/<repo>/new_issue
  69. POST /api/0/<namespace>/<repo>/new_issue
  70. ::
  71. POST /api/0/fork/<username>/<repo>/new_issue
  72. POST /api/0/fork/<username>/<namespace>/<repo>/new_issue
  73. Input
  74. ^^^^^
  75. +-------------------+--------+-------------+---------------------------+
  76. | Key | Type | Optionality | Description |
  77. +===================+========+=============+===========================+
  78. | ``title`` | string | Mandatory | The title of the issue |
  79. +-------------------+--------+-------------+---------------------------+
  80. | ``issue_content`` | string | Mandatory | | The description of the |
  81. | | | | issue |
  82. +-------------------+--------+-------------+---------------------------+
  83. | ``private`` | boolean| Optional | | Include this key if |
  84. | | | | you want a private issue|
  85. | | | | to be created |
  86. +-------------------+--------+-------------+---------------------------+
  87. | ``priority`` | string | Optional | | The priority to set to |
  88. | | | | this ticket from the |
  89. | | | | list of priorities set |
  90. | | | | in the project |
  91. +-------------------+--------+-------------+---------------------------+
  92. | ``milestone`` | string | Optional | | The milestone to assign |
  93. | | | | to this ticket from the |
  94. | | | | list of milestones set |
  95. | | | | in the project |
  96. +-------------------+--------+-------------+---------------------------+
  97. | ``tag`` | string | Optional | | Comma separated list of |
  98. | | | | tags to link to this |
  99. | | | | ticket from the list of |
  100. | | | | tags in the project |
  101. +-------------------+--------+-------------+---------------------------+
  102. | ``assignee`` | string | Optional | | The username of the user|
  103. | | | | to assign this ticket to|
  104. +-------------------+--------+-------------+---------------------------+
  105. Sample response
  106. ^^^^^^^^^^^^^^^
  107. ::
  108. {
  109. "issue": {
  110. "assignee": null,
  111. "blocks": [],
  112. "close_status": null,
  113. "closed_at": null,
  114. "closed_by": null,
  115. "comments": [],
  116. "content": "This issue needs attention",
  117. "custom_fields": [],
  118. "date_created": "1479458613",
  119. "depends": [],
  120. "id": 1,
  121. "milestone": null,
  122. "priority": null,
  123. "private": false,
  124. "status": "Open",
  125. "tags": [],
  126. "title": "test issue",
  127. "user": {
  128. "fullname": "PY C",
  129. "name": "pingou"
  130. }
  131. },
  132. "message": "Issue created"
  133. }
  134. """
  135. output = {}
  136. repo = _get_repo(repo, username, namespace)
  137. _check_issue_tracker(repo)
  138. _check_token(repo, project_token=False)
  139. user_obj = pagure.lib.query.get_user(
  140. flask.g.session, flask.g.fas_user.username
  141. )
  142. if not user_obj:
  143. raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOUSER)
  144. form = pagure.forms.IssueFormSimplied(
  145. priorities=repo.priorities,
  146. milestones=repo.milestones,
  147. csrf_enabled=False,
  148. )
  149. if form.validate_on_submit():
  150. title = form.title.data
  151. content = form.issue_content.data
  152. milestone = form.milestone.data or None
  153. private = is_true(form.private.data)
  154. priority = form.priority.data or None
  155. assignee = get_request_data().get("assignee", "").strip() or None
  156. tags = [
  157. tag.strip()
  158. for tag in get_request_data().get("tag", "").split(",")
  159. if tag.strip()
  160. ]
  161. try:
  162. issue = pagure.lib.query.new_issue(
  163. flask.g.session,
  164. repo=repo,
  165. title=title,
  166. content=content,
  167. private=private,
  168. assignee=assignee,
  169. milestone=milestone,
  170. priority=priority,
  171. tags=tags,
  172. user=flask.g.fas_user.username,
  173. )
  174. flask.g.session.flush()
  175. # If there is a file attached, attach it.
  176. filestream = flask.request.files.get("filestream")
  177. if filestream and "<!!image>" in issue.content:
  178. new_filename = pagure.lib.query.add_attachment(
  179. repo=repo,
  180. issue=issue,
  181. attachmentfolder=pagure_config["ATTACHMENTS_FOLDER"],
  182. user=user_obj,
  183. filename=filestream.filename,
  184. filestream=filestream.stream,
  185. )
  186. # Replace the <!!image> tag in the comment with the link
  187. # to the actual image
  188. filelocation = flask.url_for(
  189. "ui_ns.view_issue_raw_file",
  190. repo=repo.name,
  191. username=username,
  192. filename="files/%s" % new_filename,
  193. )
  194. new_filename = new_filename.split("-", 1)[1]
  195. url = "[![%s](%s)](%s)" % (
  196. new_filename,
  197. filelocation,
  198. filelocation,
  199. )
  200. issue.content = issue.content.replace("<!!image>", url)
  201. flask.g.session.add(issue)
  202. flask.g.session.flush()
  203. flask.g.session.commit()
  204. output["message"] = "Issue created"
  205. output["issue"] = issue.to_json(public=True)
  206. except SQLAlchemyError as err: # pragma: no cover
  207. flask.g.session.rollback()
  208. _log.exception(err)
  209. raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)
  210. else:
  211. raise pagure.exceptions.APIError(
  212. 400, error_code=APIERROR.EINVALIDREQ, errors=form.errors
  213. )
  214. jsonout = flask.jsonify(output)
  215. return jsonout
  216. @API.route("/<namespace>/<repo>/issues")
  217. @API.route("/fork/<username>/<repo>/issues")
  218. @API.route("/<repo>/issues")
  219. @API.route("/fork/<username>/<namespace>/<repo>/issues")
  220. @api_login_optional()
  221. @api_method
  222. def api_view_issues(repo, username=None, namespace=None):
  223. """
  224. List project's issues
  225. ---------------------
  226. List issues of a project.
  227. ::
  228. GET /api/0/<repo>/issues
  229. GET /api/0/<namespace>/<repo>/issues
  230. ::
  231. GET /api/0/fork/<username>/<repo>/issues
  232. GET /api/0/fork/<username>/<namespace>/<repo>/issues
  233. Parameters
  234. ^^^^^^^^^^
  235. +---------------+---------+--------------+---------------------------+
  236. | Key | Type | Optionality | Description |
  237. +===============+=========+==============+===========================+
  238. | ``status`` | string | Optional | | Filters the status of |
  239. | | | | issues. Fetches all the |
  240. | | | | issues if status is |
  241. | | | | ``all``. Default: |
  242. | | | | ``Open`` |
  243. +---------------+---------+--------------+---------------------------+
  244. | ``tags`` | string | Optional | | A list of tags you |
  245. | | | | wish to filter. If |
  246. | | | | you want to filter |
  247. | | | | for issues not having |
  248. | | | | a tag, add an |
  249. | | | | exclamation mark in |
  250. | | | | front of it |
  251. +---------------+---------+--------------+---------------------------+
  252. | ``assignee`` | string | Optional | | Filter the issues |
  253. | | | | by assignee |
  254. +---------------+---------+--------------+---------------------------+
  255. | ``author`` | string | Optional | | Filter the issues |
  256. | | | | by creator |
  257. +---------------+---------+--------------+---------------------------+
  258. | ``milestones``| list of | Optional | | Filter the issues |
  259. | | strings | | by milestone |
  260. +---------------+---------+--------------+---------------------------+
  261. | ``priority`` | string | Optional | | Filter the issues |
  262. | | | | by priority |
  263. +---------------+---------+--------------+---------------------------+
  264. | ``no_stones`` | boolean | Optional | | If true returns only the|
  265. | | | | issues having no |
  266. | | | | milestone, if false |
  267. | | | | returns only the issues |
  268. | | | | having a milestone |
  269. +---------------+---------+--------------+---------------------------+
  270. | ``since`` | string | Optional | | Filter the issues |
  271. | | | | updated after this date.|
  272. | | | | The date can either be |
  273. | | | | provided as an unix date|
  274. | | | | or in the format Y-M-D |
  275. +---------------+---------+--------------+---------------------------+
  276. | ``order`` | string | Optional | | Set the ordering of the |
  277. | | | | issues. This can be |
  278. | | | | ``asc`` or ``desc``. |
  279. | | | | Default: ``desc`` |
  280. +---------------+---------+--------------+---------------------------+
  281. | ``page`` | int | Optional | | Specifies which |
  282. | | | | page to return |
  283. | | | | (defaults to: 1) |
  284. +---------------+----------+-------------+---------------------------+
  285. | ``per_page`` | int | Optional | | The number of projects |
  286. | | | | to return per page. |
  287. | | | | The maximum is 100. |
  288. +---------------+----------+-------------+---------------------------+
  289. Sample response
  290. ^^^^^^^^^^^^^^^
  291. ::
  292. {
  293. "args": {
  294. "assignee": null,
  295. "author": null,
  296. 'milestones': [],
  297. 'no_stones': null,
  298. 'order': null,
  299. 'priority': null,
  300. "since": null,
  301. "status": "Closed",
  302. "tags": [
  303. "0.1"
  304. ]
  305. },
  306. "total_issues": 1,
  307. "issues": [
  308. {
  309. "assignee": null,
  310. "blocks": ["1"],
  311. "close_status": null,
  312. "closed_at": null,
  313. "closed_by": null,
  314. "comments": [],
  315. "content": "asd",
  316. "custom_fields": [],
  317. "date_created": "1427442217",
  318. "depends": [],
  319. "id": 4,
  320. "last_updated": "1533815358",
  321. "milestone": null,
  322. "priority": null,
  323. "private": false,
  324. "status": "Fixed",
  325. "tags": [
  326. "0.1"
  327. ],
  328. "title": "bug",
  329. "user": {
  330. "fullname": "PY.C",
  331. "name": "pingou"
  332. }
  333. }
  334. ],
  335. 'pagination': {
  336. 'first': 'http://localhost/api/0/test/issues?per_page=20&page=1',
  337. 'last': 'http://localhost/api/0/test/issues?per_page=20&page=1',
  338. 'next': null,
  339. 'page': 1,
  340. 'pages': 1,
  341. 'per_page': 20,
  342. 'prev': null
  343. },
  344. }
  345. """
  346. repo = _get_repo(repo, username, namespace)
  347. _check_issue_tracker(repo)
  348. _check_token(repo, project_token=False)
  349. assignee = flask.request.args.get("assignee", None)
  350. author = flask.request.args.get("author", None)
  351. milestone = flask.request.args.getlist("milestones", None)
  352. no_stones = flask.request.args.get("no_stones", None)
  353. if no_stones is not None:
  354. no_stones = is_true(no_stones)
  355. priority = flask.request.args.get("priority", None)
  356. since = flask.request.args.get("since", None)
  357. order = flask.request.args.get("order", None)
  358. status = flask.request.args.get("status", None)
  359. tags = flask.request.args.getlist("tags")
  360. tags = [tag.strip() for tag in tags if tag.strip()]
  361. search_id = flask.request.args.get("query_id", None)
  362. priority_key = None
  363. if priority:
  364. found = False
  365. if priority in repo.priorities:
  366. found = True
  367. priority_key = int(priority)
  368. else:
  369. for key, val in repo.priorities.items():
  370. if val.lower() == priority.lower():
  371. priority_key = key
  372. found = True
  373. break
  374. if not found:
  375. raise pagure.exceptions.APIError(
  376. 400, error_code=APIERROR.EINVALIDPRIORITY
  377. )
  378. # Hide private tickets
  379. private = False
  380. # If user is authenticated, show him/her his/her private tickets
  381. if api_authenticated():
  382. private = flask.g.fas_user.username
  383. # If user is repo committer, show all tickets included the private ones
  384. if is_repo_committer(repo):
  385. private = None
  386. params = {
  387. "session": flask.g.session,
  388. "repo": repo,
  389. "tags": tags,
  390. "assignee": assignee,
  391. "author": author,
  392. "private": private,
  393. "milestones": milestone,
  394. "priority": priority_key,
  395. "order": order,
  396. "no_milestones": no_stones,
  397. "search_id": search_id,
  398. }
  399. if status is not None:
  400. if status.lower() == "all":
  401. params.update({"status": None})
  402. elif status.lower() == "closed":
  403. params.update({"closed": True})
  404. else:
  405. params.update({"status": status})
  406. else:
  407. params.update({"status": "Open"})
  408. updated_after = None
  409. if since:
  410. # Validate and convert the time
  411. if since.isdigit():
  412. # We assume its a timestamp, so convert it to datetime
  413. try:
  414. updated_after = arrow.get(int(since)).datetime
  415. except ValueError:
  416. raise pagure.exceptions.APIError(
  417. 400, error_code=APIERROR.ETIMESTAMP
  418. )
  419. else:
  420. # We assume datetime format, so validate it
  421. try:
  422. updated_after = datetime.datetime.strptime(since, "%Y-%m-%d")
  423. except ValueError:
  424. raise pagure.exceptions.APIError(
  425. 400, error_code=APIERROR.EDATETIME
  426. )
  427. params.update({"updated_after": updated_after})
  428. page = get_page()
  429. per_page = get_per_page()
  430. params["count"] = True
  431. issue_cnt = pagure.lib.query.search_issues(**params)
  432. pagination_metadata = pagure.lib.query.get_pagination_metadata(
  433. flask.request, page, per_page, issue_cnt
  434. )
  435. query_start = (page - 1) * per_page
  436. query_limit = per_page
  437. params["count"] = False
  438. params["limit"] = query_limit
  439. params["offset"] = query_start
  440. issues = pagure.lib.query.search_issues(**params)
  441. jsonout = flask.jsonify(
  442. {
  443. "total_issues": len(issues),
  444. "issues": [issue.to_json(public=True) for issue in issues],
  445. "args": {
  446. "assignee": assignee,
  447. "author": author,
  448. "milestones": milestone,
  449. "no_stones": no_stones,
  450. "order": order,
  451. "priority": priority,
  452. "since": since,
  453. "status": status,
  454. "tags": tags,
  455. },
  456. "pagination": pagination_metadata,
  457. }
  458. )
  459. return jsonout
  460. @API.route("/<repo>/issue/<issueid>")
  461. @API.route("/<namespace>/<repo>/issue/<issueid>")
  462. @API.route("/fork/<username>/<repo>/issue/<issueid>")
  463. @API.route("/fork/<username>/<namespace>/<repo>/issue/<issueid>")
  464. @api_login_optional()
  465. @api_method
  466. def api_view_issue(repo, issueid, username=None, namespace=None):
  467. """
  468. Issue information
  469. -----------------
  470. Retrieve information of a specific issue.
  471. ::
  472. GET /api/0/<repo>/issue/<issue id>
  473. GET /api/0/<namespace>/<repo>/issue/<issue id>
  474. ::
  475. GET /api/0/fork/<username>/<repo>/issue/<issue id>
  476. GET /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>
  477. The identifier provided can be either the unique identifier or the
  478. regular identifier used in the UI (for example ``24`` in
  479. ``/forks/user/test/issue/24``)
  480. Sample response
  481. ^^^^^^^^^^^^^^^
  482. ::
  483. {
  484. "assignee": null,
  485. "blocks": [],
  486. "comments": [],
  487. "content": "This issue needs attention",
  488. "date_created": "1431414800",
  489. "depends": ["4"],
  490. "id": 1,
  491. "private": false,
  492. "status": "Open",
  493. "tags": [],
  494. "title": "test issue",
  495. "user": {
  496. "fullname": "PY C",
  497. "name": "pingou"
  498. }
  499. }
  500. """
  501. comments = is_true(flask.request.args.get("comments", True))
  502. repo = _get_repo(repo, username, namespace)
  503. _check_issue_tracker(repo)
  504. _check_token(repo)
  505. issue_id = issue_uid = None
  506. try:
  507. issue_id = int(issueid)
  508. except (ValueError, TypeError):
  509. issue_uid = issueid
  510. issue = _get_issue(repo, issue_id, issueuid=issue_uid)
  511. _check_private_issue_access(issue)
  512. jsonout = flask.jsonify(issue.to_json(public=True, with_comments=comments))
  513. return jsonout
  514. @API.route("/<repo>/issue/<issueid>", methods=["POST"])
  515. @API.route("/<namespace>/<repo>/issue/<issueid>", methods=["POST"])
  516. @API.route("/fork/<username>/<repo>/issue/<issueid>", methods=["POST"])
  517. @API.route(
  518. "/fork/<username>/<namespace>/<repo>/issue/<issueid>", methods=["POST"]
  519. )
  520. @api_login_required(acls=["issue_update"])
  521. @api_method
  522. def api_issue_update(repo, issueid, username=None, namespace=None):
  523. """
  524. Update issue information
  525. ------------------------
  526. Update the title and issue content of an existing issue.
  527. ::
  528. POST /api/0/<repo>/issue/<issue_id>
  529. POST /api/0/<namespace>/<repo>/issue/<issue_id>
  530. ::
  531. POST /api/0/fork/<username>/<repo>/issue/<issue_id>
  532. POST /api/0/fork/<username>/<namespace>/<repo>/issue/<issue_id>
  533. Input
  534. ^^^^^
  535. +-------------------+--------+-------------+---------------------------+
  536. | Key | Type | Optionality | Description |
  537. +===================+========+=============+===========================+
  538. | ``title`` | string | Mandatory | The title of the issue |
  539. +-------------------+--------+-------------+---------------------------+
  540. | ``issue_content`` | string | Mandatory | | The description of the |
  541. | | | | issue |
  542. +-------------------+--------+-------------+---------------------------+
  543. Sample response
  544. ^^^^^^^^^^^^^^^
  545. ::
  546. {
  547. "issue": {
  548. "assignee": null,
  549. "blocks": [],
  550. "close_status": null,
  551. "closed_at": null,
  552. "closed_by": null,
  553. "comments": [],
  554. "content": "This issue needs attention",
  555. "custom_fields": [],
  556. "date_created": "1479458613",
  557. "depends": [],
  558. "id": 1,
  559. "milestone": null,
  560. "priority": null,
  561. "private": false,
  562. "status": "Open",
  563. "tags": [],
  564. "title": "test issue",
  565. "user": {
  566. "fullname": "PY C",
  567. "name": "pingou"
  568. }
  569. },
  570. "message": "Issue edited"
  571. }
  572. """
  573. output = {}
  574. repo = _get_repo(repo, username, namespace)
  575. _check_issue_tracker(repo)
  576. _check_token(repo)
  577. issue_id = issue_uid = None
  578. try:
  579. issue_id = int(issueid)
  580. except (ValueError, TypeError):
  581. issue_uid = issueid
  582. issue = _get_issue(repo, issue_id, issueuid=issue_uid)
  583. _check_private_issue_access(issue)
  584. form = pagure.forms.IssueFormSimplied(csrf_enabled=False)
  585. if form.validate_on_submit():
  586. title = form.title.data.strip()
  587. content = form.issue_content.data
  588. try:
  589. pagure.lib.query.edit_issue(
  590. session=flask.g.session,
  591. issue=issue,
  592. user=flask.g.fas_user.username,
  593. title=title,
  594. content=content,
  595. )
  596. flask.g.session.commit()
  597. output["message"] = "Issue edited"
  598. output["issue"] = issue.to_json(public=True)
  599. except SQLAlchemyError as err:
  600. flask.g.session.rollback()
  601. _log.exception(err)
  602. raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)
  603. else:
  604. raise pagure.exceptions.APIError(
  605. 400, error_code=APIERROR.EINVALIDREQ, errors=form.errors
  606. )
  607. jsonout = flask.jsonify(output)
  608. return jsonout
  609. @API.route("/<repo>/issue/<issueid>/comment/<int:commentid>")
  610. @API.route("/<namespace>/<repo>/issue/<issueid>/comment/<int:commentid>")
  611. @API.route("/fork/<username>/<repo>/issue/<issueid>/comment/<int:commentid>")
  612. @API.route(
  613. "/fork/<username>/<namespace>/<repo>/issue/<issueid>/"
  614. "comment/<int:commentid>"
  615. )
  616. @api_login_optional()
  617. @api_method
  618. def api_view_issue_comment(
  619. repo, issueid, commentid, username=None, namespace=None
  620. ):
  621. """
  622. Comment of an issue
  623. --------------------
  624. Retrieve a specific comment of an issue.
  625. ::
  626. GET /api/0/<repo>/issue/<issue id>/comment/<comment id>
  627. GET /api/0/<namespace>/<repo>/issue/<issue id>/comment/<comment id>
  628. ::
  629. GET /api/0/fork/<username>/<repo>/issue/<issue id>/comment/<comment id>
  630. GET /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/comment/<comment id>
  631. The identifier provided can be either the unique identifier or the
  632. regular identifier used in the UI (for example ``24`` in
  633. ``/forks/user/test/issue/24``)
  634. Sample response
  635. ^^^^^^^^^^^^^^^
  636. ::
  637. {
  638. "avatar_url": "https://seccdn.libravatar.org/avatar/...",
  639. "comment": "9",
  640. "comment_date": "2015-07-01 15:08",
  641. "date_created": "1435756127",
  642. "id": 464,
  643. "parent": null,
  644. "user": {
  645. "fullname": "P.-Y.C.",
  646. "name": "pingou"
  647. }
  648. }
  649. """ # noqa: E501
  650. repo = _get_repo(repo, username, namespace)
  651. _check_issue_tracker(repo)
  652. _check_token(repo)
  653. issue_id = issue_uid = None
  654. try:
  655. issue_id = int(issueid)
  656. except (ValueError, TypeError):
  657. issue_uid = issueid
  658. issue = _get_issue(repo, issue_id, issueuid=issue_uid)
  659. _check_private_issue_access(issue)
  660. comment = pagure.lib.query.get_issue_comment(
  661. flask.g.session, issue.uid, commentid
  662. )
  663. if not comment:
  664. raise pagure.exceptions.APIError(404, error_code=APIERROR.ENOCOMMENT)
  665. output = comment.to_json(public=True)
  666. output["avatar_url"] = pagure.lib.query.avatar_url_from_email(
  667. comment.user.default_email, size=16
  668. )
  669. output["comment_date"] = comment.date_created.strftime("%Y-%m-%d %H:%M:%S")
  670. jsonout = flask.jsonify(output)
  671. return jsonout
  672. @API.route("/<repo>/issue/<int:issueid>/status", methods=["POST"])
  673. @API.route("/<namespace>/<repo>/issue/<int:issueid>/status", methods=["POST"])
  674. @API.route(
  675. "/fork/<username>/<repo>/issue/<int:issueid>/status", methods=["POST"]
  676. )
  677. @API.route(
  678. "/fork/<username>/<namespace>/<repo>/issue/<int:issueid>/status",
  679. methods=["POST"],
  680. )
  681. @api_login_required(acls=["issue_change_status", "issue_update"])
  682. @api_method
  683. def api_change_status_issue(repo, issueid, username=None, namespace=None):
  684. """
  685. Change issue status
  686. -------------------
  687. Change the status of an issue.
  688. ::
  689. POST /api/0/<repo>/issue/<issue id>/status
  690. POST /api/0/<namespace>/<repo>/issue/<issue id>/status
  691. ::
  692. POST /api/0/fork/<username>/<repo>/issue/<issue id>/status
  693. POST /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/status
  694. Input
  695. ^^^^^
  696. +------------------+---------+--------------+------------------------+
  697. | Key | Type | Optionality | Description |
  698. +==================+=========+==============+========================+
  699. | ``close_status`` | string | Optional | The close status of |
  700. | | | | the issue |
  701. +------------------+---------+--------------+------------------------+
  702. | ``status`` | string | Mandatory | The new status of the |
  703. | | | | issue, can be 'Open' or|
  704. | | | | 'Closed' |
  705. +------------------+---------+--------------+------------------------+
  706. Sample response
  707. ^^^^^^^^^^^^^^^
  708. ::
  709. {
  710. "message": "Successfully edited issue #1"
  711. }
  712. """
  713. output = {}
  714. repo = _get_repo(repo, username, namespace)
  715. _check_issue_tracker(repo)
  716. _check_token(repo, project_token=False)
  717. issue = _get_issue(repo, issueid)
  718. open_access = repo.settings.get("open_metadata_access_to_all", False)
  719. _check_ticket_access(issue, assignee=True, open_access=open_access)
  720. status = pagure.lib.query.get_issue_statuses(flask.g.session)
  721. form = pagure.forms.StatusForm(
  722. status=status, close_status=repo.close_status, csrf_enabled=False
  723. )
  724. close_status = None
  725. if form.close_status.raw_data:
  726. close_status = form.close_status.data
  727. new_status = form.status.data.strip()
  728. if new_status in repo.close_status and not close_status:
  729. close_status = new_status
  730. new_status = "Closed"
  731. form.status.data = new_status
  732. if form.validate_on_submit():
  733. try:
  734. # Update status
  735. message = pagure.lib.query.edit_issue(
  736. flask.g.session,
  737. issue=issue,
  738. status=new_status,
  739. close_status=close_status,
  740. user=flask.g.fas_user.username,
  741. )
  742. flask.g.session.commit()
  743. if message:
  744. output["message"] = message
  745. else:
  746. output["message"] = "No changes"
  747. if message:
  748. pagure.lib.query.add_metadata_update_notif(
  749. session=flask.g.session,
  750. obj=issue,
  751. messages=message,
  752. user=flask.g.fas_user.username,
  753. )
  754. except pagure.exceptions.PagureException as err:
  755. raise pagure.exceptions.APIError(
  756. 400, error_code=APIERROR.ENOCODE, error=str(err)
  757. )
  758. except SQLAlchemyError: # pragma: no cover
  759. flask.g.session.rollback()
  760. raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)
  761. else:
  762. raise pagure.exceptions.APIError(
  763. 400, error_code=APIERROR.EINVALIDREQ, errors=form.errors
  764. )
  765. jsonout = flask.jsonify(output)
  766. return jsonout
  767. @API.route("/<repo>/issue/<int:issueid>/milestone", methods=["POST"])
  768. @API.route(
  769. "/<namespace>/<repo>/issue/<int:issueid>/milestone", methods=["POST"]
  770. )
  771. @API.route(
  772. "/fork/<username>/<repo>/issue/<int:issueid>/milestone", methods=["POST"]
  773. )
  774. @API.route(
  775. "/fork/<username>/<namespace>/<repo>/issue/<int:issueid>/milestone",
  776. methods=["POST"],
  777. )
  778. @api_login_required(acls=["issue_update_milestone", "issue_update"])
  779. @api_method
  780. def api_change_milestone_issue(repo, issueid, username=None, namespace=None):
  781. """
  782. Change issue milestone
  783. ----------------------
  784. Change the milestone of an issue.
  785. ::
  786. POST /api/0/<repo>/issue/<issue id>/milestone
  787. POST /api/0/<namespace>/<repo>/issue/<issue id>/milestone
  788. ::
  789. POST /api/0/fork/<username>/<repo>/issue/<issue id>/milestone
  790. POST /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/milestone
  791. Input
  792. ^^^^^
  793. +------------------+---------+--------------+------------------------+
  794. | Key | Type | Optionality | Description |
  795. +==================+=========+==============+========================+
  796. | ``milestone`` | string | Optional | The new milestone of |
  797. | | | | the issue, can be any |
  798. | | | | of defined milestones |
  799. | | | | or empty to unset the |
  800. | | | | milestone |
  801. +------------------+---------+--------------+------------------------+
  802. Sample response
  803. ^^^^^^^^^^^^^^^
  804. ::
  805. {
  806. "message": "Successfully edited issue #1"
  807. }
  808. """ # noqa
  809. output = {}
  810. repo = _get_repo(repo, username, namespace)
  811. _check_issue_tracker(repo)
  812. _check_token(repo)
  813. issue = _get_issue(repo, issueid)
  814. open_access = repo.settings.get("open_metadata_access_to_all", False)
  815. _check_ticket_access(issue, open_access=open_access)
  816. form = pagure.forms.MilestoneForm(
  817. milestones=repo.milestones.keys(), csrf_enabled=False
  818. )
  819. if form.validate_on_submit():
  820. new_milestone = form.milestone.data or None
  821. try:
  822. # Update status
  823. message = pagure.lib.query.edit_issue(
  824. flask.g.session,
  825. issue=issue,
  826. milestone=new_milestone,
  827. user=flask.g.fas_user.username,
  828. )
  829. flask.g.session.commit()
  830. if message:
  831. output["message"] = message
  832. else:
  833. output["message"] = "No changes"
  834. if message:
  835. pagure.lib.query.add_metadata_update_notif(
  836. session=flask.g.session,
  837. obj=issue,
  838. messages=message,
  839. user=flask.g.fas_user.username,
  840. )
  841. except pagure.exceptions.PagureException as err:
  842. raise pagure.exceptions.APIError(
  843. 400, error_code=APIERROR.ENOCODE, error=str(err)
  844. )
  845. except SQLAlchemyError: # pragma: no cover
  846. flask.g.session.rollback()
  847. raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)
  848. else:
  849. raise pagure.exceptions.APIError(
  850. 400, error_code=APIERROR.EINVALIDREQ, errors=form.errors
  851. )
  852. jsonout = flask.jsonify(output)
  853. return jsonout
  854. @API.route("/<repo>/issue/<int:issueid>/comment", methods=["POST"])
  855. @API.route("/<namespace>/<repo>/issue/<int:issueid>/comment", methods=["POST"])
  856. @API.route(
  857. "/fork/<username>/<repo>/issue/<int:issueid>/comment", methods=["POST"]
  858. )
  859. @API.route(
  860. "/fork/<username>/<namespace>/<repo>/issue/<int:issueid>/comment",
  861. methods=["POST"],
  862. )
  863. @api_login_required(acls=["issue_comment", "issue_update"])
  864. @api_method
  865. def api_comment_issue(repo, issueid, username=None, namespace=None):
  866. """
  867. Comment to an issue
  868. -------------------
  869. Add a comment to an issue.
  870. ::
  871. POST /api/0/<repo>/issue/<issue id>/comment
  872. POST /api/0/<namespace>/<repo>/issue/<issue id>/comment
  873. ::
  874. POST /api/0/fork/<username>/<repo>/issue/<issue id>/comment
  875. POST /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/comment
  876. Input
  877. ^^^^^
  878. +--------------+----------+---------------+---------------------------+
  879. | Key | Type | Optionality | Description |
  880. +==============+==========+===============+===========================+
  881. | ``comment`` | string | Mandatory | | The comment to add to |
  882. | | | | the issue |
  883. +--------------+----------+---------------+---------------------------+
  884. Sample response
  885. ^^^^^^^^^^^^^^^
  886. ::
  887. {
  888. "message": "Comment added"
  889. }
  890. """
  891. output = {}
  892. repo = _get_repo(repo, username, namespace)
  893. _check_issue_tracker(repo)
  894. _check_token(repo, project_token=False)
  895. issue = _get_issue(repo, issueid)
  896. _check_private_issue_access(issue)
  897. form = pagure.forms.CommentForm(csrf_enabled=False)
  898. if form.validate_on_submit():
  899. comment = form.comment.data
  900. try:
  901. # New comment
  902. message = pagure.lib.query.add_issue_comment(
  903. flask.g.session,
  904. issue=issue,
  905. comment=comment,
  906. user=flask.g.fas_user.username,
  907. )
  908. flask.g.session.commit()
  909. output["message"] = message
  910. except SQLAlchemyError as err: # pragma: no cover
  911. flask.g.session.rollback()
  912. _log.exception(err)
  913. raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)
  914. else:
  915. raise pagure.exceptions.APIError(
  916. 400, error_code=APIERROR.EINVALIDREQ, errors=form.errors
  917. )
  918. output["avatar_url"] = pagure.lib.query.avatar_url_from_email(
  919. flask.g.fas_user.default_email, size=30
  920. )
  921. output["user"] = flask.g.fas_user.username
  922. jsonout = flask.jsonify(output)
  923. return jsonout
  924. @API.route("/<repo>/issue/<int:issueid>/assign", methods=["POST"])
  925. @API.route("/<namespace>/<repo>/issue/<int:issueid>/assign", methods=["POST"])
  926. @API.route(
  927. "/fork/<username>/<repo>/issue/<int:issueid>/assign", methods=["POST"]
  928. )
  929. @API.route(
  930. "/fork/<username>/<namespace>/<repo>/issue/<int:issueid>/assign",
  931. methods=["POST"],
  932. )
  933. @api_login_required(acls=["issue_assign", "issue_update"])
  934. @api_method
  935. def api_assign_issue(repo, issueid, username=None, namespace=None):
  936. """
  937. Assign an issue
  938. ---------------
  939. Assign an issue to someone.
  940. ::
  941. POST /api/0/<repo>/issue/<issue id>/assign
  942. POST /api/0/<namespace>/<repo>/issue/<issue id>/assign
  943. ::
  944. POST /api/0/fork/<username>/<repo>/issue/<issue id>/assign
  945. POST /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/assign
  946. Input
  947. ^^^^^
  948. +--------------+----------+---------------+---------------------------+
  949. | Key | Type | Optionality | Description |
  950. +==============+==========+===============+===========================+
  951. | ``assignee`` | string | Mandatory | | The username of the user|
  952. | | | | to assign the issue to. |
  953. +--------------+----------+---------------+---------------------------+
  954. Sample response
  955. ^^^^^^^^^^^^^^^
  956. ::
  957. {
  958. "message": "Issue assigned"
  959. }
  960. """
  961. output = {}
  962. repo = _get_repo(repo, username, namespace)
  963. _check_issue_tracker(repo)
  964. _check_token(repo)
  965. issue = _get_issue(repo, issueid)
  966. open_access = repo.settings.get("open_metadata_access_to_all", False)
  967. _check_ticket_access(issue, assignee=True, open_access=open_access)
  968. form = pagure.forms.AssignIssueForm(csrf_enabled=False)
  969. if form.validate_on_submit():
  970. assignee = form.assignee.data or None
  971. # Create our metadata comment object
  972. try:
  973. # New comment
  974. message = pagure.lib.query.add_issue_assignee(
  975. flask.g.session,
  976. issue=issue,
  977. assignee=assignee,
  978. user=flask.g.fas_user.username,
  979. )
  980. flask.g.session.commit()
  981. if message:
  982. pagure.lib.query.add_metadata_update_notif(
  983. session=flask.g.session,
  984. obj=issue,
  985. messages=message,
  986. user=flask.g.fas_user.username,
  987. )
  988. output["message"] = message
  989. else:
  990. output["message"] = "Nothing to change"
  991. except pagure.exceptions.PagureException as err: # pragma: no cover
  992. raise pagure.exceptions.APIError(
  993. 400, error_code=APIERROR.ENOCODE, error=str(err)
  994. )
  995. except SQLAlchemyError as err: # pragma: no cover
  996. flask.g.session.rollback()
  997. _log.exception(err)
  998. raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)
  999. jsonout = flask.jsonify(output)
  1000. return jsonout
  1001. @API.route("/<repo>/issue/<int:issueid>/subscribe", methods=["POST"])
  1002. @API.route(
  1003. "/<namespace>/<repo>/issue/<int:issueid>/subscribe", methods=["POST"]
  1004. )
  1005. @API.route(
  1006. "/fork/<username>/<repo>/issue/<int:issueid>/subscribe", methods=["POST"]
  1007. )
  1008. @API.route(
  1009. "/fork/<username>/<namespace>/<repo>/issue/<int:issueid>/subscribe",
  1010. methods=["POST"],
  1011. )
  1012. @api_login_required(acls=["issue_subscribe"])
  1013. @api_method
  1014. def api_subscribe_issue(repo, issueid, username=None, namespace=None):
  1015. """
  1016. Subscribe to an issue
  1017. ---------------------
  1018. Allows someone to subscribe to or unsubscribe from the notifications
  1019. related to an issue.
  1020. ::
  1021. POST /api/0/<repo>/issue/<issue id>/subscribe
  1022. POST /api/0/<namespace>/<repo>/issue/<issue id>/subscribe
  1023. ::
  1024. POST /api/0/fork/<username>/<repo>/issue/<issue id>/subscribe
  1025. POST /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/subscribe
  1026. Input
  1027. ^^^^^
  1028. +--------------+----------+---------------+---------------------------+
  1029. | Key | Type | Optionality | Description |
  1030. +==============+==========+===============+===========================+
  1031. | ``status`` | boolean | Mandatory | The intended subscription |
  1032. | | | | status. ``true`` for |
  1033. | | | | subscribing, ``false`` |
  1034. | | | | for unsubscribing. |
  1035. +--------------+----------+---------------+---------------------------+
  1036. Sample response
  1037. ^^^^^^^^^^^^^^^
  1038. ::
  1039. {
  1040. "message": "User subscribed",
  1041. "avatar_url": "https://image.png",
  1042. "user": "pingou"
  1043. }
  1044. """ # noqa
  1045. output = {}
  1046. repo = _get_repo(repo, username, namespace)
  1047. _check_issue_tracker(repo)
  1048. _check_token(repo)
  1049. issue = _get_issue(repo, issueid)
  1050. _check_private_issue_access(issue)
  1051. form = pagure.forms.SubscribtionForm(csrf_enabled=False)
  1052. if form.validate_on_submit():
  1053. status = is_true(form.status.data)
  1054. try:
  1055. # Toggle subscribtion
  1056. message = pagure.lib.query.set_watch_obj(
  1057. flask.g.session,
  1058. user=flask.g.fas_user.username,
  1059. obj=issue,
  1060. watch_status=status,
  1061. )
  1062. flask.g.session.commit()
  1063. output["message"] = message
  1064. user_obj = pagure.lib.query.get_user(
  1065. flask.g.session, flask.g.fas_user.username
  1066. )
  1067. output["avatar_url"] = pagure.lib.query.avatar_url_from_email(
  1068. user_obj.default_email, size=30
  1069. )
  1070. output["user"] = flask.g.fas_user.username
  1071. except SQLAlchemyError as err: # pragma: no cover
  1072. flask.g.session.rollback()
  1073. _log.exception(err)
  1074. raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)
  1075. jsonout = flask.jsonify(output)
  1076. return jsonout
  1077. @API.route("/<repo>/issue/<int:issueid>/custom/<field>", methods=["POST"])
  1078. @API.route(
  1079. "/<namespace>/<repo>/issue/<int:issueid>/custom/<field>", methods=["POST"]
  1080. )
  1081. @API.route(
  1082. "/fork/<username>/<repo>/issue/<int:issueid>/custom/<field>",
  1083. methods=["POST"],
  1084. )
  1085. @API.route(
  1086. "/fork/<username>/<namespace>/<repo>/issue/<int:issueid>/custom/<field>",
  1087. methods=["POST"],
  1088. )
  1089. @api_login_required(acls=["issue_update_custom_fields", "issue_update"])
  1090. @api_method
  1091. def api_update_custom_field(
  1092. repo, issueid, field, username=None, namespace=None
  1093. ):
  1094. """
  1095. Update custom field
  1096. -------------------
  1097. Update or reset the content of a custom field associated to an issue.
  1098. ::
  1099. POST /api/0/<repo>/issue/<issue id>/custom/<field>
  1100. POST /api/0/<namespace>/<repo>/issue/<issue id>/custom/<field>
  1101. ::
  1102. POST /api/0/fork/<username>/<repo>/issue/<issue id>/custom/<field>
  1103. POST /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/custom/<field>
  1104. Input
  1105. ^^^^^
  1106. +------------------+---------+--------------+-------------------------+
  1107. | Key | Type | Optionality | Description |
  1108. +==================+=========+==============+=========================+
  1109. | ``value`` | string | Optional | The new value of the |
  1110. | | | | custom field of interest|
  1111. +------------------+---------+--------------+-------------------------+
  1112. Sample response
  1113. ^^^^^^^^^^^^^^^
  1114. ::
  1115. {
  1116. "message": "Custom field adjusted"
  1117. }
  1118. """ # noqa
  1119. output = {}
  1120. repo = _get_repo(repo, username, namespace)
  1121. _check_issue_tracker(repo)
  1122. _check_token(repo)
  1123. issue = _get_issue(repo, issueid)
  1124. open_access = repo.settings.get("open_metadata_access_to_all", False)
  1125. _check_ticket_access(issue, open_access=open_access)
  1126. fields = {k.name: k for k in repo.issue_keys}
  1127. if field not in fields:
  1128. raise pagure.exceptions.APIError(
  1129. 400, error_code=APIERROR.EINVALIDISSUEFIELD
  1130. )
  1131. key = fields[field]
  1132. value = get_request_data().get("value")
  1133. if value:
  1134. _check_link_custom_field(key, value)
  1135. try:
  1136. message = pagure.lib.query.set_custom_key_value(
  1137. flask.g.session, issue, key, value
  1138. )
  1139. flask.g.session.commit()
  1140. if message:
  1141. output["message"] = message
  1142. pagure.lib.query.add_metadata_update_notif(
  1143. session=flask.g.session,
  1144. obj=issue,
  1145. messages=message,
  1146. user=flask.g.fas_user.username,
  1147. )
  1148. else:
  1149. output["message"] = "No changes"
  1150. except pagure.exceptions.PagureException as err:
  1151. raise pagure.exceptions.APIError(
  1152. 400, error_code=APIERROR.ENOCODE, error=str(err)
  1153. )
  1154. except SQLAlchemyError as err: # pragma: no cover
  1155. print(err)
  1156. flask.g.session.rollback()
  1157. raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)
  1158. jsonout = flask.jsonify(output)
  1159. return jsonout
  1160. @API.route("/<repo>/issue/<int:issueid>/custom", methods=["POST"])
  1161. @API.route("/<namespace>/<repo>/issue/<int:issueid>/custom", methods=["POST"])
  1162. @API.route(
  1163. "/fork/<username>/<repo>/issue/<int:issueid>/custom", methods=["POST"]
  1164. )
  1165. @API.route(
  1166. "/fork/<username>/<namespace>/<repo>/issue/<int:issueid>/custom",
  1167. methods=["POST"],
  1168. )
  1169. @api_login_required(acls=["issue_update_custom_fields", "issue_update"])
  1170. @api_method
  1171. def api_update_custom_fields(repo, issueid, username=None, namespace=None):
  1172. """
  1173. Update custom fields
  1174. --------------------
  1175. Update or reset the content of a collection of custom fields
  1176. associated to an issue.
  1177. ::
  1178. POST /api/0/<repo>/issue/<issue id>/custom
  1179. POST /api/0/<namespace>/<repo>/issue/<issue id>/custom
  1180. ::
  1181. POST /api/0/fork/<username>/<repo>/issue/<issue id>/custom
  1182. POST /api/0/fork/<username>/<namespace>/<repo>/issue/<issue id>/custom
  1183. Input
  1184. ^^^^^
  1185. +------------------+---------+--------------+-----------------------------+
  1186. | Key | Type | Optionality | Description |
  1187. +==================+=========+==============+=============================+
  1188. | ``myfields`` | dict | Mandatory | A dictionary with the fields|
  1189. | | | | name as key and the value |
  1190. +------------------+---------+--------------+-----------------------------+
  1191. Sample payload
  1192. ^^^^^^^^^^^^^^
  1193. ::
  1194. {
  1195. "myField": "to do",
  1196. "myField_1": "test",
  1197. "myField_2": "done",
  1198. }
  1199. Sample response
  1200. ^^^^^^^^^^^^^^^
  1201. ::
  1202. {
  1203. "messages": [
  1204. {
  1205. "myField" : "Custom field myField adjusted to to do"
  1206. },
  1207. {
  1208. "myField_1": "Custom field myField_1 adjusted test (was: to do)"
  1209. },
  1210. {
  1211. "myField_2": "Custom field myField_1 adjusted to done (was: test)"
  1212. }
  1213. ]
  1214. }
  1215. """ # noqa
  1216. output = {"messages": []}
  1217. repo = _get_repo(repo, username, namespace)
  1218. _check_issue_tracker(repo)
  1219. _check_token(repo)
  1220. issue = _get_issue(repo, issueid)
  1221. open_access = repo.settings.get("open_metadata_access_to_all", False)
  1222. _check_ticket_access(issue, open_access=open_access)
  1223. fields = get_request_data()
  1224. if not fields:
  1225. raise pagure.exceptions.APIError(400, error_code=APIERROR.EINVALIDREQ)
  1226. repo_fields = {k.name: k for k in repo.issue_keys}
  1227. if not all(key in repo_fields.keys() for key in fields.keys()):
  1228. raise pagure.exceptions.APIError(
  1229. 400, error_code=APIERROR.EINVALIDISSUEFIELD
  1230. )
  1231. for field in fields:
  1232. key = repo_fields[field]
  1233. value = fields.get(key.name)
  1234. if value:
  1235. _check_link_custom_field(key, value)
  1236. try:
  1237. message = pagure.lib.query.set_custom_key_value(
  1238. flask.g.session, issue, key, value
  1239. )
  1240. flask.g.session.commit()
  1241. if message:
  1242. output["messages"].append({key.name: message})
  1243. pagure.lib.query.add_metadata_update_notif(
  1244. session=flask.g.session,
  1245. obj=issue,
  1246. messages=message,
  1247. user=flask.g.fas_user.username,
  1248. )
  1249. else:
  1250. output["messages"].append({key.name: "No changes"})
  1251. except pagure.exceptions.PagureException as err:
  1252. raise pagure.exceptions.APIError(
  1253. 400, error_code=APIERROR.ENOCODE, error=str(err)
  1254. )
  1255. except SQLAlchemyError as err: # pragma: no cover
  1256. print(err)
  1257. flask.g.session.rollback()
  1258. raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)
  1259. jsonout = flask.jsonify(output)
  1260. return jsonout
  1261. @API.route("/<repo>/issues/history/stats")
  1262. @API.route("/<namespace>/<repo>/issues/history/stats")
  1263. @API.route("/fork/<username>/<repo>/issues/history/stats")
  1264. @API.route("/fork/<username>/<namespace>/<repo>/issues/history/stats")
  1265. @api_method
  1266. def api_view_issues_history_stats(repo, username=None, namespace=None):
  1267. """
  1268. List project's statistical issues history.
  1269. ------------------------------------------
  1270. Provides the number of opened issues over the last 6 months of the
  1271. project.
  1272. ::
  1273. GET /api/0/<repo>/issues/history/stats
  1274. GET /api/0/<namespace>/<repo>/issues/history/stats
  1275. ::
  1276. GET /api/0/fork/<username>/<repo>/issues/history/stats
  1277. GET /api/0/fork/<username>/<namespace>/<repo>/issues/history/stats
  1278. Sample response
  1279. ^^^^^^^^^^^^^^^
  1280. ::
  1281. {
  1282. "stats": {
  1283. ...
  1284. "2017-09-19T13:10:51.041345": 6,
  1285. "2017-09-26T13:10:51.041345": 6,
  1286. "2017-10-03T13:10:51.041345": 6,
  1287. "2017-10-10T13:10:51.041345": 6,
  1288. "2017-10-17T13:10:51.041345": 6
  1289. }
  1290. }
  1291. """
  1292. repo = _get_repo(repo, username, namespace)
  1293. _check_issue_tracker(repo)
  1294. stats = pagure.lib.query.issues_history_stats(flask.g.session, repo)
  1295. jsonout = flask.jsonify({"stats": stats})
  1296. return jsonout