boards.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801
  1. # -*- coding: utf-8 -*-
  2. """
  3. (c) 2020 - Copyright Red Hat Inc
  4. Authors:
  5. Pierre-Yves Chibon <pingou@pingoured.fr>
  6. """
  7. from __future__ import unicode_literals, absolute_import
  8. import logging
  9. import flask
  10. from sqlalchemy.exc import SQLAlchemyError
  11. import werkzeug.datastructures
  12. import pagure
  13. import pagure.exceptions
  14. import pagure.lib.query
  15. import pagure.lib.tasks
  16. from pagure.forms import TAGS_REGEX, TAGS_REGEX_RE
  17. from pagure.api import (
  18. API,
  19. api_method,
  20. api_login_required,
  21. APIERROR,
  22. get_request_data,
  23. )
  24. from pagure.api.utils import (
  25. _get_repo,
  26. _check_token,
  27. _check_issue_tracker,
  28. )
  29. _log = logging.getLogger(__name__)
  30. @API.route("/<repo>/boards")
  31. @API.route("/<namespace>/<repo>/boards")
  32. @API.route("/fork/<username>/<repo>/boards")
  33. @API.route("/fork/<username>/<namespace>/<repo>/boards")
  34. @api_method
  35. def api_boards_view(repo, username=None, namespace=None):
  36. """
  37. List a project's boards
  38. -----------------------
  39. Retrieve the list of boards a project has.
  40. ::
  41. GET /api/0/<repo>/boards
  42. GET /api/0/<namespace>/<repo>/boards
  43. ::
  44. GET /api/0/fork/<username>/<repo>/boards
  45. GET /api/0/fork/<username>/<namespace>/<repo>/boards
  46. Sample response
  47. ^^^^^^^^^^^^^^^
  48. ::
  49. {
  50. "total_boards": 3,
  51. "boards": [
  52. {"name": "infrastructure", "active": true},
  53. {"name": "releng", "active": true},
  54. {"name": "initiatives", "active": true},
  55. ]
  56. }
  57. """
  58. repo = _get_repo(repo, username, namespace)
  59. _check_issue_tracker(repo)
  60. boards = repo.boards
  61. jsonout = {
  62. "total_requests": len(boards),
  63. "boards": [board.to_json() for board in boards],
  64. }
  65. return flask.jsonify(jsonout)
  66. @API.route("/<repo>/boards", methods=["POST"])
  67. @API.route("/<namespace>/<repo>/boards", methods=["POST"])
  68. @API.route("/fork/<username>/<repo>/boards", methods=["POST"])
  69. @API.route(
  70. "/fork/<username>/<namespace>/<repo>/boards",
  71. methods=["POST"],
  72. )
  73. @api_login_required(acls=["modify_project"])
  74. @api_method
  75. def api_board_create(repo, username=None, namespace=None):
  76. """
  77. Create a board
  78. --------------
  79. Create a new board on a project
  80. ::
  81. POST /api/0/<repo>/boards
  82. POST /api/0/<namespace>/<repo>/boards
  83. ::
  84. POST /api/0/fork/<username>/<repo>/boards
  85. POST /api/0/fork/<username>/<namespace>/<repo>/boards
  86. Input
  87. ^^^^^
  88. {
  89. "board_name": {
  90. "active": <boolean>,
  91. "tag": <string>
  92. },
  93. "Infrastructure": {
  94. "active": true,
  95. "tag": "backlog"
  96. }
  97. }
  98. Sample response
  99. ^^^^^^^^^^^^^^^
  100. ::
  101. {
  102. "boards": [
  103. {
  104. "active": True,
  105. "name": "dev",
  106. "status": [],
  107. "tag": {
  108. "tag": "dev",
  109. "tag_color": "DeepBlueSky",
  110. "tag_description": "",
  111. },
  112. },
  113. {
  114. "active": True,
  115. "name": "infra",
  116. "status": [],
  117. "tag": {
  118. "tag": "infra",
  119. "tag_color": "DeepGreen",
  120. "tag_description": "",
  121. },
  122. },
  123. ]
  124. }
  125. """ # noqa
  126. repo = _get_repo(repo, username, namespace)
  127. _check_issue_tracker(repo)
  128. _check_token(repo, project_token=False)
  129. data = flask.request.get_json() or {}
  130. if not data:
  131. raise pagure.exceptions.APIError(
  132. 400,
  133. error_code=APIERROR.EINVALIDREQ,
  134. errors="No (JSON) data provided",
  135. )
  136. for key in data:
  137. if not isinstance(data[key], bool) and "tag" not in data[key]:
  138. raise pagure.exceptions.APIError(
  139. 400,
  140. error_code=APIERROR.EINVALIDREQ,
  141. errors="No tag associated with at least one of the boards",
  142. )
  143. names = list(data.keys())
  144. existing_board_names = set(board.name for board in repo.boards)
  145. removing_names = set(existing_board_names) - set(names)
  146. for name in data:
  147. if name not in existing_board_names:
  148. try:
  149. pagure.lib.query.create_board(
  150. flask.g.session,
  151. project=repo,
  152. name=name,
  153. active=data[name].get("active", False),
  154. tag=data[name]["tag"],
  155. )
  156. flask.g.session.commit()
  157. except pagure.exceptions.PagureException as err:
  158. raise pagure.exceptions.APIError(
  159. 400, error_code=APIERROR.ENOCODE, error=str(err)
  160. )
  161. except SQLAlchemyError as err: # pragma: no cover
  162. flask.g.session.rollback()
  163. _log.exception(err)
  164. raise pagure.exceptions.APIError(
  165. 400, error_code=APIERROR.EDBERROR
  166. )
  167. else:
  168. try:
  169. pagure.lib.query.edit_board(
  170. flask.g.session,
  171. project=repo,
  172. name=name,
  173. active=data[name].get("active", False),
  174. tag=data[name]["tag"],
  175. )
  176. flask.g.session.commit()
  177. except pagure.exceptions.PagureException as err:
  178. raise pagure.exceptions.APIError(
  179. 400, error_code=APIERROR.ENOCODE, error=str(err)
  180. )
  181. except SQLAlchemyError as err: # pragma: no cover
  182. flask.g.session.rollback()
  183. _log.exception(err)
  184. raise pagure.exceptions.APIError(
  185. 400, error_code=APIERROR.EDBERROR
  186. )
  187. if removing_names:
  188. try:
  189. pagure.lib.query.delete_board(
  190. flask.g.session,
  191. project=repo,
  192. names=removing_names,
  193. )
  194. flask.g.session.commit()
  195. except SQLAlchemyError as err: # pragma: no cover
  196. flask.g.session.rollback()
  197. _log.exception(err)
  198. raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)
  199. return flask.jsonify(
  200. {"boards": [board.to_json() for board in repo.boards]}
  201. )
  202. @API.route("/<repo>/boards/delete", methods=["POST"])
  203. @API.route("/<namespace>/<repo>/boards/delete", methods=["POST"])
  204. @API.route("/fork/<username>/<repo>/boards/delete", methods=["POST"])
  205. @API.route(
  206. "/fork/<username>/<namespace>/<repo>/boards/delete",
  207. methods=["POST"],
  208. )
  209. @api_login_required(acls=["modify_project"])
  210. @api_method
  211. def api_board_delete(repo, username=None, namespace=None):
  212. """
  213. Delete a board
  214. ---------------
  215. Delet a board of a project
  216. ::
  217. POST /api/0/<repo>/boards/delete
  218. POST /api/0/<namespace>/<repo>/boards/delete
  219. ::
  220. POST /api/0/fork/<username>/<repo>/boards/delete
  221. POST /api/0/fork/<username>/<namespace>/<repo>/boards/delete
  222. Input
  223. ^^^^^
  224. +---------------------+---------+-------------+-----------------------------+
  225. | Key | Type | Optionality | Description |
  226. +=====================+=========+=============+=============================+
  227. | ``name`` | string | Mandatory | | The name of the board to |
  228. | | | | delete. |
  229. +---------------------+---------+-------------+-----------------------------+
  230. Sample response
  231. ^^^^^^^^^^^^^^^
  232. ::
  233. {
  234. }
  235. """ # noqa
  236. repo = _get_repo(repo, username, namespace)
  237. _check_issue_tracker(repo)
  238. _check_token(repo, project_token=False)
  239. fields = get_request_data()
  240. if not isinstance(fields, werkzeug.datastructures.ImmutableMultiDict):
  241. names_in = fields.get("name") or []
  242. else:
  243. names_in = fields.getlist("name")
  244. names = []
  245. for idx, name in enumerate(names_in):
  246. if name.strip():
  247. names.append(name)
  248. if not names:
  249. raise pagure.exceptions.APIError(
  250. 400,
  251. error_code=APIERROR.EINVALIDREQ,
  252. errors={"name": ["This field is required"]},
  253. )
  254. try:
  255. pagure.lib.query.delete_board(
  256. flask.g.session,
  257. project=repo,
  258. names=names,
  259. )
  260. flask.g.session.commit()
  261. except SQLAlchemyError as err: # pragma: no cover
  262. flask.g.session.rollback()
  263. _log.exception(err)
  264. raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)
  265. repo = _get_repo(repo.name, username, namespace)
  266. return flask.jsonify(
  267. {"boards": [board.to_json() for board in repo.boards]}
  268. )
  269. @API.route("/<repo>/boards/<board_name>/status", methods=["POST"])
  270. @API.route("/<namespace>/<repo>/boards/<board_name>/status", methods=["POST"])
  271. @API.route(
  272. "/fork/<username>/<repo>/boards/<board_name>/status", methods=["POST"]
  273. )
  274. @API.route(
  275. "/fork/<username>/<namespace>/<repo>/boards/<board_name>/status",
  276. methods=["POST"],
  277. )
  278. @api_login_required(acls=["modify_project"])
  279. @api_method
  280. def api_board_status(repo, board_name, username=None, namespace=None):
  281. """
  282. Update board statuses
  283. ---------------------
  284. Set or update the statuses a board has.
  285. ::
  286. POST /api/0/<repo>/boards
  287. POST /api/0/<namespace>/<repo>/boards
  288. ::
  289. POST /api/0/fork/<username>/<repo>/boards
  290. POST /api/0/fork/<username>/<namespace>/<repo>/boards
  291. Input
  292. ^^^^^
  293. Submitted as JSON (Requires setting a
  294. ``contentType: 'application/json; charset=utf-8'`` header):
  295. ::
  296. {
  297. "Triaged": {
  298. "close": false,
  299. "close_status": "",
  300. "bg_color": "#ca0dcd",
  301. "default": true,
  302. "rank": 1
  303. },
  304. "In Progress": {
  305. "close": false,
  306. "close_status": "",
  307. "bg_color": "#1780ec",
  308. "default": false,
  309. "rank": 2
  310. },
  311. "In Review": {
  312. "close": false,
  313. "close_status": "",
  314. "bg_color": "#f28b20",
  315. "default": false,
  316. "rank": 3
  317. },
  318. "Done": {
  319. "close": true,
  320. "close_status": "Fixed",
  321. "bg_color": "#34d240",
  322. "default": false,
  323. "rank": 4
  324. },
  325. "Blocked": {
  326. "close": false,
  327. "close_status": "",
  328. "bg_color": "#ff0022",
  329. "default": false,
  330. "rank": 5
  331. }
  332. }
  333. Sample response
  334. ^^^^^^^^^^^^^^^
  335. ::
  336. {
  337. "board": {
  338. "active": True,
  339. "name": "dev",
  340. "status": [
  341. {
  342. "bg_color": "#FFB300",
  343. "close": false,
  344. "close_status": None,
  345. "name": "Backlog",
  346. },
  347. {
  348. "bg_color": "#ca0eef",
  349. "close": false,
  350. "close_status": None,
  351. "name": "In Progress",
  352. },
  353. {
  354. "name": "Done",
  355. "close": true,
  356. "close_status": "Fixed",
  357. "bg_color": "#34d240",
  358. },
  359. ],
  360. "tag": {
  361. "tag": "dev",
  362. "tag_color": "DeepBlueSky",
  363. "tag_description": "",
  364. },
  365. }
  366. }
  367. """ # noqa
  368. repo = _get_repo(repo, username, namespace)
  369. _check_issue_tracker(repo)
  370. _check_token(repo, project_token=False)
  371. board = None
  372. for board_obj in repo.boards:
  373. if board_obj.name == board_name:
  374. board = board_obj
  375. break
  376. if board is None:
  377. raise pagure.exceptions.APIError(
  378. 404,
  379. error_code=APIERROR.EINVALIDREQ,
  380. errors="Board not found",
  381. )
  382. data = flask.request.get_json() or {}
  383. if not data:
  384. raise pagure.exceptions.APIError(
  385. 400,
  386. error_code=APIERROR.EINVALIDREQ,
  387. errors="No (JSON) data provided",
  388. )
  389. defaults = []
  390. for key in data:
  391. if key.strip():
  392. if not TAGS_REGEX_RE.match(key):
  393. raise pagure.exceptions.APIError(
  394. 400,
  395. error_code=APIERROR.EINVALIDREQ,
  396. errors={
  397. "name": [
  398. "Invalid status name provided, it "
  399. "should match: %s." % TAGS_REGEX
  400. ]
  401. },
  402. )
  403. if (
  404. len(
  405. set(data[key].keys()).intersection(
  406. set(["rank", "default"])
  407. )
  408. )
  409. != 2
  410. ):
  411. raise pagure.exceptions.APIError(
  412. 400,
  413. error_code=APIERROR.EINVALIDREQ,
  414. errors="The 'rank' and 'default' fields are" " mandatory.",
  415. )
  416. if data[key]["default"] is True:
  417. defaults.append(key)
  418. if len(defaults) != 1:
  419. raise pagure.exceptions.APIError(
  420. 400,
  421. error_code=APIERROR.EINVALIDREQ,
  422. errors="There must be one and only one default.",
  423. )
  424. for status in board.statuses:
  425. if status.name not in data:
  426. _log.debug("Removing status: %s", status.name)
  427. flask.g.session.delete(status)
  428. for name in data:
  429. if not name.strip():
  430. continue
  431. try:
  432. close_status = data[name].get("close_status") or None
  433. close = data[name].get("close") or (
  434. True if close_status else False
  435. )
  436. if close_status not in repo.close_status:
  437. close_status = None
  438. pagure.lib.query.update_board_status(
  439. flask.g.session,
  440. board=board,
  441. name=name,
  442. rank=data[name]["rank"],
  443. default=data[name]["default"],
  444. bg_color=data[name].get("bg_color") or None,
  445. close=close,
  446. close_status=close_status,
  447. )
  448. flask.g.session.commit()
  449. except SQLAlchemyError as err: # pragma: no cover
  450. flask.g.session.rollback()
  451. _log.exception(err)
  452. raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)
  453. return flask.jsonify({"board": board.to_json()})
  454. @API.route("/<repo>/boards/<board_name>/update_issue", methods=["POST"])
  455. @API.route(
  456. "/<namespace>/<repo>/boards/<board_name>/update_issue", methods=["POST"]
  457. )
  458. @API.route(
  459. "/fork/<username>/<repo>/boards/<board_name>/update_issue",
  460. methods=["POST"],
  461. )
  462. @API.route(
  463. "/fork/<username>/<namespace>/<repo>/boards/<board_name>/update_issue",
  464. methods=["POST"],
  465. )
  466. @api_login_required(acls=["modify_project"])
  467. @api_method
  468. def api_board_ticket_update_status(
  469. repo, board_name, username=None, namespace=None
  470. ):
  471. """
  472. Update a ticket on a board
  473. --------------------------
  474. Update a ticket on a board (ie: update its status).
  475. ::
  476. POST /api/0/<repo>/boards/update_issue
  477. POST /api/0/<namespace>/<repo>/boards/update_issue
  478. ::
  479. POST /api/0/fork/<username>/<repo>/boards/update_issue
  480. POST /api/0/fork/<username>/<namespace>/<repo>/boards/update_issue
  481. Input
  482. ^^^^^
  483. Submitted as JSON (Requires setting a
  484. ``contentType: 'application/json; charset=utf-8'`` header):
  485. ::
  486. {
  487. "ticket_uid": {
  488. "status": "status_name"
  489. "rank": 1
  490. },
  491. "asdas12e1dasdasd12e12e": {
  492. "status": "In Progress"
  493. "rank": 2
  494. }
  495. }
  496. Sample response
  497. ^^^^^^^^^^^^^^^
  498. ::
  499. {
  500. {"name": "infrastructure", "active": true},
  501. }
  502. """ # noqa
  503. repo = _get_repo(repo, username, namespace)
  504. _check_issue_tracker(repo)
  505. _check_token(repo, project_token=False)
  506. board = None
  507. for board_obj in repo.boards:
  508. if board_obj.name == board_name:
  509. board = board_obj
  510. break
  511. if board is None:
  512. raise pagure.exceptions.APIError(
  513. 404,
  514. error_code=APIERROR.EINVALIDREQ,
  515. errors="Board not found",
  516. )
  517. data = flask.request.get_json() or {}
  518. if not data:
  519. raise pagure.exceptions.APIError(
  520. 400,
  521. error_code=APIERROR.EINVALIDREQ,
  522. errors="No (JSON) data provided",
  523. )
  524. for key in data:
  525. if key.strip():
  526. if (
  527. len(
  528. set(data[key].keys()).intersection(set(["rank", "status"]))
  529. )
  530. != 2
  531. ):
  532. raise pagure.exceptions.APIError(
  533. 400,
  534. error_code=APIERROR.EINVALIDREQ,
  535. errors="The 'rank' and 'status' fields are mandatory.",
  536. )
  537. for ticket_uid in data:
  538. if not ticket_uid.strip():
  539. continue
  540. try:
  541. pagure.lib.query.update_ticket_board_status(
  542. flask.g.session,
  543. board=board,
  544. user=flask.g.fas_user.username,
  545. ticket_uid=ticket_uid,
  546. rank=data[ticket_uid]["rank"],
  547. status_name=data[ticket_uid]["status"],
  548. )
  549. flask.g.session.commit()
  550. except pagure.exceptions.PagureException as err:
  551. raise pagure.exceptions.APIError(
  552. 400, error_code=APIERROR.ENOCODE, error=str(err)
  553. )
  554. except SQLAlchemyError as err: # pragma: no cover
  555. flask.g.session.rollback()
  556. _log.exception(err)
  557. raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)
  558. return flask.jsonify({"board": board_obj.to_json()})
  559. @API.route("/<repo>/boards/<board_name>/add_issue", methods=["POST"])
  560. @API.route(
  561. "/<namespace>/<repo>/boards/<board_name>/add_issue", methods=["POST"]
  562. )
  563. @API.route(
  564. "/fork/<username>/<repo>/boards/<board_name>/add_issue",
  565. methods=["POST"],
  566. )
  567. @API.route(
  568. "/fork/<username>/<namespace>/<repo>/boards/<board_name>/add_issue",
  569. methods=["POST"],
  570. )
  571. @api_login_required(acls=["modify_project"])
  572. @api_method
  573. def api_board_ticket_add_status(
  574. repo, board_name, username=None, namespace=None
  575. ):
  576. """
  577. Add a ticket on a board
  578. --------------------------
  579. Add a ticket on a board (ie: update its status).
  580. ::
  581. POST /api/0/<repo>/boards/update_issue
  582. POST /api/0/<namespace>/<repo>/boards/update_issue
  583. ::
  584. POST /api/0/fork/<username>/<repo>/boards/update_issue
  585. POST /api/0/fork/<username>/<namespace>/<repo>/boards/update_issue
  586. Input
  587. ^^^^^
  588. Submitted as JSON (Requires setting a
  589. ``contentType: 'application/json; charset=utf-8'`` header):
  590. ::
  591. {
  592. "ticket_id_in_the_project": {
  593. "status": "status_name"
  594. "rank": 1
  595. },
  596. "12": {
  597. "status": "In Progress"
  598. "rank": 2
  599. }
  600. }
  601. Sample response
  602. ^^^^^^^^^^^^^^^
  603. ::
  604. {
  605. {"name": "infrastructure", "active": true},
  606. }
  607. """ # noqa
  608. repo = _get_repo(repo, username, namespace)
  609. _check_issue_tracker(repo)
  610. _check_token(repo, project_token=False)
  611. board = None
  612. for board_obj in repo.boards:
  613. if board_obj.name == board_name:
  614. board = board_obj
  615. break
  616. if board is None:
  617. raise pagure.exceptions.APIError(
  618. 404,
  619. error_code=APIERROR.EINVALIDREQ,
  620. errors="Board not found",
  621. )
  622. data = flask.request.get_json() or {}
  623. if not data:
  624. raise pagure.exceptions.APIError(
  625. 400,
  626. error_code=APIERROR.EINVALIDREQ,
  627. errors="No (JSON) data provided",
  628. )
  629. for key in data:
  630. if key.strip():
  631. if (
  632. len(
  633. set(data[key].keys()).intersection(set(["rank", "status"]))
  634. )
  635. != 2
  636. ):
  637. raise pagure.exceptions.APIError(
  638. 400,
  639. error_code=APIERROR.EINVALIDREQ,
  640. errors="The 'rank' and 'status' fields are mandatory.",
  641. )
  642. for ticket_id in data:
  643. if not ticket_id.strip():
  644. continue
  645. try:
  646. pagure.lib.query.update_ticket_board_status(
  647. flask.g.session,
  648. board=board,
  649. user=flask.g.fas_user.username,
  650. ticket_id=ticket_id,
  651. rank=data[ticket_id]["rank"],
  652. status_name=data[ticket_id]["status"],
  653. )
  654. flask.g.session.commit()
  655. except pagure.exceptions.PagureException as err:
  656. raise pagure.exceptions.APIError(
  657. 400, error_code=APIERROR.ENOCODE, error=str(err)
  658. )
  659. except SQLAlchemyError as err: # pragma: no cover
  660. flask.g.session.rollback()
  661. _log.exception(err)
  662. raise pagure.exceptions.APIError(400, error_code=APIERROR.EDBERROR)
  663. return flask.jsonify({"board": board_obj.to_json()})