model.py 92 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100
  1. # -*- coding: utf-8 -*-
  2. """
  3. (c) 2014-2018 - Copyright Red Hat Inc
  4. Authors:
  5. Pierre-Yves Chibon <pingou@pingoured.fr>
  6. """
  7. from __future__ import unicode_literals
  8. __requires__ = ["SQLAlchemy >= 0.8", "jinja2 >= 2.4"] # noqa
  9. import pkg_resources # noqa: E402,F401
  10. import arrow
  11. import datetime
  12. import collections
  13. import logging
  14. import json
  15. import operator
  16. import re
  17. import pygit2
  18. import os
  19. import six
  20. import sqlalchemy as sa
  21. from sqlalchemy import create_engine
  22. from sqlalchemy.exc import SQLAlchemyError
  23. from sqlalchemy.orm import backref
  24. from sqlalchemy.orm import sessionmaker
  25. from sqlalchemy.orm import scoped_session
  26. from sqlalchemy.orm import relation
  27. from sqlalchemy.orm import validates
  28. import pagure.exceptions
  29. from pagure.config import config as pagure_config
  30. from pagure.lib.model_base import BASE
  31. from pagure.lib.plugins import get_plugin_tables
  32. from pagure.utils import is_true
  33. _log = logging.getLogger(__name__)
  34. # hit w/ all the id field we use
  35. # pylint: disable=invalid-name
  36. # pylint: disable=too-few-public-methods
  37. # pylint: disable=no-init
  38. # pylint: disable=too-many-lines
  39. def create_tables(db_url, alembic_ini=None, acls=None, debug=False):
  40. """ Create the tables in the database using the information from the
  41. url obtained.
  42. :arg db_url, URL used to connect to the database. The URL contains
  43. information with regards to the database engine, the host to
  44. connect to, the user and password and the database name.
  45. ie: <engine>://<user>:<password>@<host>/<dbname>
  46. :kwarg alembic_ini, path to the alembic ini file. This is necessary
  47. to be able to use alembic correctly, but not for the unit-tests.
  48. :kwarg debug, a boolean specifying whether we should have the verbose
  49. output of sqlalchemy or not.
  50. :return a session that can be used to query the database.
  51. """
  52. if db_url.startswith("postgres"): # pragma: no cover
  53. engine = create_engine(db_url, echo=debug, client_encoding="utf8")
  54. else: # pragma: no cover
  55. engine = create_engine(db_url, echo=debug)
  56. get_plugin_tables()
  57. BASE.metadata.create_all(engine)
  58. # engine.execute(collection_package_create_view(driver=engine.driver))
  59. if db_url.startswith("sqlite:"):
  60. # Ignore the warning about con_record
  61. # pylint: disable=unused-argument
  62. def _fk_pragma_on_connect(dbapi_con, _): # pragma: no cover
  63. """ Tries to enforce referential constraints on sqlite. """
  64. dbapi_con.execute("pragma foreign_keys=ON")
  65. sa.event.listen(engine, "connect", _fk_pragma_on_connect)
  66. if alembic_ini is not None: # pragma: no cover
  67. # then, load the Alembic configuration and generate the
  68. # version table, "stamping" it with the most recent rev:
  69. # Ignore the warning missing alembic
  70. # pylint: disable=import-error
  71. from alembic.config import Config
  72. from alembic import command
  73. alembic_cfg = Config(alembic_ini)
  74. command.stamp(alembic_cfg, "head")
  75. scopedsession = scoped_session(sessionmaker(bind=engine))
  76. BASE.metadata.bind = scopedsession
  77. # Insert the default data into the db
  78. create_default_status(scopedsession, acls=acls)
  79. return scopedsession
  80. def create_default_status(session, acls=None):
  81. """ Insert the defaults status in the status tables.
  82. """
  83. statuses = ["Open", "Closed"]
  84. for status in statuses:
  85. ticket_stat = StatusIssue(status=status)
  86. session.add(ticket_stat)
  87. try:
  88. session.commit()
  89. except SQLAlchemyError: # pragma: no cover
  90. session.rollback()
  91. _log.debug("Status %s could not be added", ticket_stat)
  92. for status in ["Open", "Closed", "Merged"]:
  93. pr_stat = StatusPullRequest(status=status)
  94. session.add(pr_stat)
  95. try:
  96. session.commit()
  97. except SQLAlchemyError: # pragma: no cover
  98. session.rollback()
  99. _log.debug("Status %s could not be added", pr_stat)
  100. for grptype in ["user", "admin"]:
  101. grp_type = PagureGroupType(group_type=grptype)
  102. session.add(grp_type)
  103. try:
  104. session.commit()
  105. except SQLAlchemyError: # pragma: no cover
  106. session.rollback()
  107. _log.debug("Type %s could not be added", grptype)
  108. acls = acls or {}
  109. keys = sorted(list(acls.keys()))
  110. for acl in keys:
  111. item = ACL(name=acl, description=acls[acl])
  112. session.add(item)
  113. try:
  114. session.commit()
  115. except SQLAlchemyError: # pragma: no cover
  116. session.rollback()
  117. _log.debug("ACL %s could not be added", acl)
  118. for access in ["ticket", "commit", "admin"]:
  119. access_obj = AccessLevels(access=access)
  120. session.add(access_obj)
  121. try:
  122. session.commit()
  123. except SQLAlchemyError:
  124. session.rollback()
  125. _log.debug("Access level %s could not be added", access)
  126. def arrow_ts(value):
  127. return "%s" % arrow.get(value).timestamp
  128. class AccessLevels(BASE):
  129. """ Different access levels a user/group can have for a project """
  130. __tablename__ = "access_levels"
  131. access = sa.Column(sa.String(255), primary_key=True)
  132. class StatusIssue(BASE):
  133. """ Stores the status a ticket can have.
  134. Table -- status_issue
  135. """
  136. __tablename__ = "status_issue"
  137. id = sa.Column(sa.Integer, primary_key=True)
  138. status = sa.Column(sa.String(255), nullable=False, unique=True)
  139. class StatusPullRequest(BASE):
  140. """ Stores the status a pull-request can have.
  141. Table -- status_issue
  142. """
  143. __tablename__ = "status_pull_requests"
  144. id = sa.Column(sa.Integer, primary_key=True)
  145. status = sa.Column(sa.String(255), nullable=False, unique=True)
  146. class User(BASE):
  147. """ Stores information about users.
  148. Table -- users
  149. """
  150. __tablename__ = "users"
  151. id = sa.Column(sa.Integer, primary_key=True)
  152. user = sa.Column(sa.String(255), nullable=False, unique=True, index=True)
  153. fullname = sa.Column(sa.String(255), nullable=False, index=True)
  154. default_email = sa.Column(sa.Text, nullable=False)
  155. _settings = sa.Column(sa.Text, nullable=True)
  156. password = sa.Column(sa.Text, nullable=True)
  157. token = sa.Column(sa.String(50), nullable=True)
  158. created = sa.Column(sa.DateTime, nullable=False, default=sa.func.now())
  159. updated_on = sa.Column(
  160. sa.DateTime,
  161. nullable=False,
  162. default=sa.func.now(),
  163. onupdate=sa.func.now(),
  164. )
  165. refuse_sessions_before = sa.Column(
  166. sa.DateTime, nullable=True, default=None
  167. )
  168. # Relations
  169. group_objs = relation(
  170. "PagureGroup",
  171. secondary="pagure_user_group",
  172. primaryjoin="users.c.id==pagure_user_group.c.user_id",
  173. secondaryjoin="pagure_group.c.id==pagure_user_group.c.group_id",
  174. backref="users",
  175. )
  176. session = relation("PagureUserVisit", backref="user")
  177. @property
  178. def username(self):
  179. """ Return the username. """
  180. return self.user
  181. @property
  182. def html_title(self):
  183. """ Return the ``fullname (username)`` or simply ``username`` to be
  184. used in the html templates.
  185. """
  186. if self.fullname:
  187. return "%s (%s)" % (self.fullname, self.user)
  188. else:
  189. return self.user
  190. @property
  191. def groups(self):
  192. """ Return the list of Group.group_name in which the user is. """
  193. return [group.group_name for group in self.group_objs]
  194. @property
  195. def settings(self):
  196. """ Return the dict stored as string in the database as an actual
  197. dict object.
  198. """
  199. default = {"cc_me_to_my_actions": False}
  200. if self._settings:
  201. current = json.loads(self._settings)
  202. # Update the current dict with the new keys
  203. for key in default:
  204. if key not in current:
  205. current[key] = default[key]
  206. elif is_true(current[key]):
  207. current[key] = True
  208. return current
  209. else:
  210. return default
  211. @settings.setter
  212. def settings(self, settings):
  213. """ Ensures the settings are properly saved. """
  214. self._settings = json.dumps(settings)
  215. def __repr__(self):
  216. """ Return a string representation of this object. """
  217. return "User: %s - name %s" % (self.id, self.user)
  218. def to_json(self, public=False):
  219. """ Return a representation of the User in a dictionary. """
  220. output = {"name": self.user, "fullname": self.fullname}
  221. if not public:
  222. output["default_email"] = self.default_email
  223. output["emails"] = sorted([email.email for email in self.emails])
  224. return output
  225. class UserEmail(BASE):
  226. """ Stores email information about the users.
  227. Table -- user_emails
  228. """
  229. __tablename__ = "user_emails"
  230. id = sa.Column(sa.Integer, primary_key=True)
  231. user_id = sa.Column(
  232. sa.Integer,
  233. sa.ForeignKey("users.id", onupdate="CASCADE"),
  234. nullable=False,
  235. index=True,
  236. )
  237. email = sa.Column(sa.String(255), nullable=False, unique=True)
  238. user = relation(
  239. "User",
  240. foreign_keys=[user_id],
  241. remote_side=[User.id],
  242. backref=backref(
  243. "emails", cascade="delete, delete-orphan", single_parent=True
  244. ),
  245. )
  246. class UserEmailPending(BASE):
  247. """ Stores email information about the users.
  248. Table -- user_emails_pending
  249. """
  250. __tablename__ = "user_emails_pending"
  251. id = sa.Column(sa.Integer, primary_key=True)
  252. user_id = sa.Column(
  253. sa.Integer,
  254. sa.ForeignKey("users.id", onupdate="CASCADE"),
  255. nullable=False,
  256. index=True,
  257. )
  258. email = sa.Column(sa.String(255), nullable=False, unique=True)
  259. token = sa.Column(sa.String(50), nullable=True)
  260. created = sa.Column(sa.DateTime, nullable=False, default=sa.func.now())
  261. user = relation(
  262. "User",
  263. foreign_keys=[user_id],
  264. remote_side=[User.id],
  265. backref=backref(
  266. "emails_pending",
  267. cascade="delete, delete-orphan",
  268. single_parent=True,
  269. ),
  270. )
  271. class Project(BASE):
  272. """ Stores the projects.
  273. Table -- projects
  274. """
  275. __tablename__ = "projects"
  276. id = sa.Column(sa.Integer, primary_key=True)
  277. user_id = sa.Column(
  278. sa.Integer,
  279. sa.ForeignKey("users.id", onupdate="CASCADE"),
  280. nullable=False,
  281. index=True,
  282. )
  283. namespace = sa.Column(sa.String(255), nullable=True, index=True)
  284. name = sa.Column(sa.String(255), nullable=False, index=True)
  285. description = sa.Column(sa.Text, nullable=True)
  286. url = sa.Column(sa.Text, nullable=True)
  287. _settings = sa.Column(sa.Text, nullable=True)
  288. # The hook_token is used to sign the notification sent via web-hook
  289. hook_token = sa.Column(sa.String(40), nullable=False, unique=True)
  290. avatar_email = sa.Column(sa.Text, nullable=True)
  291. is_fork = sa.Column(sa.Boolean, default=False, nullable=False)
  292. read_only = sa.Column(sa.Boolean, default=True, nullable=False)
  293. parent_id = sa.Column(
  294. sa.Integer,
  295. sa.ForeignKey("projects.id", onupdate="CASCADE"),
  296. nullable=True,
  297. )
  298. _priorities = sa.Column(sa.Text, nullable=True)
  299. default_priority = sa.Column(sa.Text, nullable=True)
  300. _milestones = sa.Column(sa.Text, nullable=True)
  301. _milestones_keys = sa.Column(sa.Text, nullable=True)
  302. _quick_replies = sa.Column(sa.Text, nullable=True)
  303. _reports = sa.Column(sa.Text, nullable=True)
  304. _notifications = sa.Column(sa.Text, nullable=True)
  305. _close_status = sa.Column(sa.Text, nullable=True)
  306. mirrored_from = sa.Column(sa.Text, nullable=True)
  307. mirrored_from_last_log = sa.Column(sa.Text, nullable=True)
  308. date_created = sa.Column(
  309. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  310. )
  311. date_modified = sa.Column(
  312. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  313. )
  314. parent = relation(
  315. "Project",
  316. remote_side=[id],
  317. backref=backref(
  318. "forks", order_by=str("(projects.c.date_created).desc()")
  319. ),
  320. )
  321. user = relation(
  322. "User",
  323. foreign_keys=[user_id],
  324. remote_side=[User.id],
  325. backref="projects",
  326. )
  327. private = sa.Column(sa.Boolean, nullable=False, default=False)
  328. repospanner_region = sa.Column(sa.Text, nullable=True)
  329. users = relation(
  330. "User",
  331. secondary="user_projects",
  332. primaryjoin="projects.c.id==user_projects.c.project_id",
  333. secondaryjoin="users.c.id==user_projects.c.user_id",
  334. backref="co_projects",
  335. )
  336. admins = relation(
  337. "User",
  338. secondary="user_projects",
  339. primaryjoin="projects.c.id==user_projects.c.project_id",
  340. secondaryjoin="and_(users.c.id==user_projects.c.user_id,\
  341. user_projects.c.access=='admin')",
  342. backref="co_projects_admins",
  343. viewonly=True,
  344. )
  345. committers = relation(
  346. "User",
  347. secondary="user_projects",
  348. primaryjoin="projects.c.id==user_projects.c.project_id",
  349. secondaryjoin="and_(users.c.id==user_projects.c.user_id,\
  350. or_(user_projects.c.access=='commit',\
  351. user_projects.c.access=='admin'))",
  352. backref="co_projects_committers",
  353. viewonly=True,
  354. )
  355. groups = relation(
  356. "PagureGroup",
  357. secondary="projects_groups",
  358. primaryjoin="projects.c.id==projects_groups.c.project_id",
  359. secondaryjoin="pagure_group.c.id==projects_groups.c.group_id",
  360. backref=backref(
  361. "projects",
  362. order_by=str(
  363. "func.lower(projects.c.namespace).desc(), "
  364. "func.lower(projects.c.name)"
  365. ),
  366. ),
  367. order_by="PagureGroup.group_name.asc()",
  368. )
  369. admin_groups = relation(
  370. "PagureGroup",
  371. secondary="projects_groups",
  372. primaryjoin="projects.c.id==projects_groups.c.project_id",
  373. secondaryjoin="and_(pagure_group.c.id==projects_groups.c.group_id,\
  374. projects_groups.c.access=='admin')",
  375. backref="projects_admin_groups",
  376. order_by="PagureGroup.group_name.asc()",
  377. viewonly=True,
  378. )
  379. committer_groups = relation(
  380. "PagureGroup",
  381. secondary="projects_groups",
  382. primaryjoin="projects.c.id==projects_groups.c.project_id",
  383. secondaryjoin="and_(pagure_group.c.id==projects_groups.c.group_id,\
  384. or_(projects_groups.c.access=='admin',\
  385. projects_groups.c.access=='commit'))",
  386. backref="projects_committer_groups",
  387. order_by="PagureGroup.group_name.asc()",
  388. viewonly=True,
  389. )
  390. @property
  391. def isa(self):
  392. """ A string to allow finding out that this is a project. """
  393. return "project"
  394. @property
  395. def mail_id(self):
  396. """ Return a unique representation of the project as string that
  397. can be used when sending emails.
  398. """
  399. return "%s-project-%s" % (self.fullname, self.id)
  400. @property
  401. def is_on_repospanner(self):
  402. """ Returns whether this repo is on repoSpanner. """
  403. return self.repospanner_region is not None
  404. @property
  405. def path(self):
  406. """ Return the name of the git repo on the filesystem. """
  407. return "%s.git" % self.fullname
  408. def repospanner_repo_info(self, repotype, region=None):
  409. """ Returns info for getting a repoSpanner repo for a project.
  410. Args:
  411. repotype (string): Type of repository
  412. region (string): If repo is not on repoSpanner, return url as if
  413. it was in this region. Used for migrating to repoSpanner.
  414. Return type: (url, dict): First is the clone url, then a dict with
  415. the regioninfo.
  416. """
  417. if not self.is_on_repospanner and region is None:
  418. raise ValueError("Repo %s is not on repoSpanner" % self.fullname)
  419. if self.is_on_repospanner and region is not None:
  420. raise ValueError(
  421. "Repo %s is already on repoSpanner" % self.fullname
  422. )
  423. if region is None:
  424. region = self.repospanner_region
  425. regioninfo = pagure_config["REPOSPANNER_REGIONS"].get(region)
  426. if not regioninfo:
  427. raise ValueError(
  428. "Invalid repoSpanner region %s looked up" % region
  429. )
  430. url = "%s/repo/%s.git" % (
  431. regioninfo["url"],
  432. self._repospanner_repo_name(repotype, region),
  433. )
  434. return url, regioninfo
  435. def _repospanner_repo_name(self, repotype, region=None):
  436. """ Returns the name of a repo as named in repoSpanner.
  437. Args:
  438. repotype (string): Type of repository
  439. region (string): repoSpanner region name
  440. Return type: (string)
  441. """
  442. if region is None:
  443. region = self.repospanner_region
  444. return os.path.join(
  445. pagure_config["REPOSPANNER_REGIONS"][region].get(
  446. "repo_prefix", ""
  447. ),
  448. repotype,
  449. self.fullname,
  450. )
  451. def repopath(self, repotype):
  452. """ Return the full repository path of the git repo on the filesystem.
  453. If the repository is on repoSpanner, this will be a pseudo repository,
  454. which is "git repo enough" to be considered a valid repo, but any
  455. access should go through a repoSpanner enlightened libgit2.
  456. """
  457. if self.is_on_repospanner:
  458. pseudopath = os.path.join(
  459. pagure_config["REPOSPANNER_PSEUDO_FOLDER"], repotype, self.path
  460. )
  461. if not os.path.exists(pseudopath):
  462. repourl, regioninfo = self.repospanner_repo_info(repotype)
  463. fake = pygit2.init_repository(pseudopath, bare=True)
  464. fake.config["repospanner.url"] = repourl
  465. fake.config["repospanner.cert"] = regioninfo["push_cert"][
  466. "cert"
  467. ]
  468. fake.config["repospanner.key"] = regioninfo["push_cert"]["key"]
  469. fake.config["repospanner.cacert"] = regioninfo["ca"]
  470. fake.config["repospanner.enabled"] = True
  471. del fake
  472. return pseudopath
  473. maindir = None
  474. if repotype == "main":
  475. maindir = pagure_config["GIT_FOLDER"]
  476. elif repotype == "docs":
  477. maindir = pagure_config["DOCS_FOLDER"]
  478. elif repotype == "tickets":
  479. maindir = pagure_config["TICKETS_FOLDER"]
  480. elif repotype == "requests":
  481. maindir = pagure_config["REQUESTS_FOLDER"]
  482. else:
  483. return ValueError("Repotype %s is invalid" % repotype)
  484. if maindir is None:
  485. if repotype == "main":
  486. raise Exception("No maindir for main repos?")
  487. return None
  488. return os.path.join(maindir, self.path)
  489. @property
  490. def fullname(self):
  491. """ Return the name of the git repo as user/project if it is a
  492. project forked, otherwise it returns the project name.
  493. """
  494. str_name = self.name
  495. if self.namespace:
  496. str_name = "%s/%s" % (self.namespace, str_name)
  497. if self.is_fork:
  498. str_name = "forks/%s/%s" % (self.user.user, str_name)
  499. return str_name
  500. @property
  501. def url_path(self):
  502. """ Return the path at which this project can be accessed in the
  503. web UI.
  504. """
  505. path = self.name
  506. if self.namespace:
  507. path = "%s/%s" % (self.namespace, path)
  508. if self.is_fork:
  509. path = "fork/%s/%s" % (self.user.user, path)
  510. return path
  511. @property
  512. def tags_text(self):
  513. """ Return the list of tags in a simple text form. """
  514. return [tag.tag for tag in self.tags]
  515. @property
  516. def settings(self):
  517. """ Return the dict stored as string in the database as an actual
  518. dict object.
  519. """
  520. default = {
  521. "issue_tracker": True,
  522. "project_documentation": False,
  523. "pull_requests": True,
  524. "Only_assignee_can_merge_pull-request": False,
  525. "Minimum_score_to_merge_pull-request": -1,
  526. "Web-hooks": None,
  527. "Enforce_signed-off_commits_in_pull-request": False,
  528. "always_merge": False,
  529. "issues_default_to_private": False,
  530. "fedmsg_notifications": True,
  531. "stomp_notifications": True,
  532. "mqtt_notifications": True,
  533. "pull_request_access_only": False,
  534. "notify_on_pull-request_flag": False,
  535. "notify_on_commit_flag": False,
  536. "issue_tracker_read_only": False,
  537. "disable_non_fast-forward_merges": False,
  538. "open_metadata_access_to_all": False,
  539. }
  540. if self._settings:
  541. current = json.loads(self._settings)
  542. # Update the current dict with the new keys
  543. for key in default:
  544. if key not in current:
  545. current[key] = default[key]
  546. elif key == "Minimum_score_to_merge_pull-request":
  547. current[key] = int(current[key])
  548. elif is_true(current[key]):
  549. current[key] = True
  550. # Update the current dict, removing the old keys
  551. for key in sorted(current):
  552. if key not in default:
  553. del current[key]
  554. return current
  555. else:
  556. return default
  557. @settings.setter
  558. def settings(self, settings):
  559. """ Ensures the settings are properly saved. """
  560. self._settings = json.dumps(settings)
  561. @property
  562. def milestones(self):
  563. """ Return the dict stored as string in the database as an actual
  564. dict object.
  565. """
  566. milestones = {}
  567. if self._milestones:
  568. def _convert_to_dict(value):
  569. if isinstance(value, dict):
  570. return value
  571. else:
  572. return {"date": value, "active": True}
  573. milestones = dict(
  574. [
  575. (k, _convert_to_dict(v))
  576. for k, v in json.loads(self._milestones).items()
  577. ]
  578. )
  579. return milestones
  580. @milestones.setter
  581. def milestones(self, milestones):
  582. """ Ensures the milestones are properly saved. """
  583. self._milestones = json.dumps(milestones)
  584. @property
  585. def milestones_keys(self):
  586. """ Return the list of milestones so we can keep the order consistent.
  587. """
  588. milestones_keys = {}
  589. if self._milestones_keys:
  590. milestones_keys = json.loads(self._milestones_keys)
  591. return milestones_keys
  592. @milestones_keys.setter
  593. def milestones_keys(self, milestones_keys):
  594. """ Ensures the milestones keys are properly saved. """
  595. self._milestones_keys = json.dumps(milestones_keys)
  596. @property
  597. def priorities(self):
  598. """ Return the dict stored as string in the database as an actual
  599. dict object.
  600. """
  601. priorities = {}
  602. if self._priorities:
  603. priorities = json.loads(self._priorities)
  604. return priorities
  605. @priorities.setter
  606. def priorities(self, priorities):
  607. """ Ensures the priorities are properly saved. """
  608. self._priorities = json.dumps(priorities)
  609. @property
  610. def quick_replies(self):
  611. """ Return a list of quick replies available for pull requests and
  612. issues.
  613. """
  614. quick_replies = []
  615. if self._quick_replies:
  616. quick_replies = json.loads(self._quick_replies)
  617. return quick_replies
  618. @quick_replies.setter
  619. def quick_replies(self, quick_replies):
  620. """ Ensures the quick replies are properly saved. """
  621. self._quick_replies = json.dumps(quick_replies)
  622. @property
  623. def notifications(self):
  624. """ Return the dict stored as string in the database as an actual
  625. dict object.
  626. """
  627. notifications = {}
  628. if self._notifications:
  629. notifications = json.loads(self._notifications)
  630. return notifications
  631. @notifications.setter
  632. def notifications(self, notifications):
  633. """ Ensures the notifications are properly saved. """
  634. self._notifications = json.dumps(notifications)
  635. @property
  636. def reports(self):
  637. """ Return the dict stored as string in the database as an actual
  638. dict object.
  639. """
  640. reports = {}
  641. if self._reports:
  642. reports = json.loads(self._reports)
  643. return reports
  644. @reports.setter
  645. def reports(self, reports):
  646. """ Ensures the reports are properly saved. """
  647. self._reports = json.dumps(reports)
  648. @property
  649. def close_status(self):
  650. """ Return the dict stored as string in the database as an actual
  651. dict object.
  652. """
  653. close_status = []
  654. if self._close_status:
  655. close_status = json.loads(self._close_status)
  656. return close_status
  657. @close_status.setter
  658. def close_status(self, close_status):
  659. """ Ensures the different close status are properly saved. """
  660. self._close_status = json.dumps(close_status)
  661. @property
  662. def open_requests(self):
  663. """ Returns the number of open pull-requests for this project. """
  664. return (
  665. BASE.metadata.bind.query(PullRequest)
  666. .filter(self.id == PullRequest.project_id)
  667. .filter(PullRequest.status == "Open")
  668. .count()
  669. )
  670. @property
  671. def open_tickets(self):
  672. """ Returns the number of open tickets for this project. """
  673. return (
  674. BASE.metadata.bind.query(Issue)
  675. .filter(self.id == Issue.project_id)
  676. .filter(Issue.status == "Open")
  677. .count()
  678. )
  679. @property
  680. def open_tickets_public(self):
  681. """ Returns the number of open tickets for this project. """
  682. return (
  683. BASE.metadata.bind.query(Issue)
  684. .filter(self.id == Issue.project_id)
  685. .filter(Issue.status == "Open")
  686. .filter(Issue.private == False) # noqa: E712
  687. .count()
  688. )
  689. @property
  690. def contributors(self):
  691. """ Return the dict presenting the different contributors of the
  692. project based on their access level.
  693. """
  694. contributors = collections.defaultdict(list)
  695. for user in self.user_projects:
  696. contributors[user.access].append(user.user)
  697. return contributors
  698. @property
  699. def contributor_groups(self):
  700. """ Return the dict presenting the different contributors of the
  701. project based on their access level.
  702. """
  703. contributors = collections.defaultdict(list)
  704. for group in self.projects_groups:
  705. contributors[group.access].append(group.group)
  706. return contributors
  707. def get_project_users(self, access, combine=True):
  708. """ Returns the list of users/groups of the project according
  709. to the given access.
  710. :arg access: the access level to query for, can be: 'admin',
  711. 'commit' or 'ticket'.
  712. :type access: string
  713. :arg combine: The access levels have some hierarchy -
  714. like: all the users having commit access also has
  715. ticket access and the admins have all the access
  716. that commit and ticket access users have. If combine
  717. is set to False, this function will only return those
  718. users which have the given access and no other access.
  719. ex: if access is 'ticket' and combine is True, it will
  720. return all the users with ticket access which includes
  721. all the committers and admins. If combine were False,
  722. it would have returned only the users with ticket access
  723. and would not have included committers and admins.
  724. :type combine: boolean
  725. """
  726. if access not in ["admin", "commit", "ticket"]:
  727. raise pagure.exceptions.AccessLevelNotFound(
  728. "The access level does not exist"
  729. )
  730. if combine:
  731. if access == "admin":
  732. return self.admins
  733. elif access == "commit":
  734. return self.committers
  735. elif access == "ticket":
  736. return self.users
  737. else:
  738. if access == "admin":
  739. return self.admins
  740. elif access == "commit":
  741. committers = set(self.committers)
  742. admins = set(self.admins)
  743. return list(committers - admins)
  744. elif access == "ticket":
  745. committers = set(self.committers)
  746. admins = set(self.admins)
  747. users = set(self.users)
  748. return list(users - committers - admins)
  749. def get_project_groups(self, access, combine=True):
  750. """ Returns the list of groups of the project according
  751. to the given access.
  752. :arg access: the access level to query for, can be: 'admin',
  753. 'commit' or 'ticket'.
  754. :type access: string
  755. :arg combine: The access levels have some hierarchy -
  756. like: all the groups having commit access also has
  757. ticket access and the admin_groups have all the access
  758. that committer_groups and ticket access groups have.
  759. If combine is set to False, this function will only return
  760. those groups which have the given access and no other access.
  761. ex: if access is 'ticket' and combine is True, it will
  762. return all the groups with ticket access which includes
  763. all the committer_groups and admin_groups. If combine were False,
  764. it would have returned only the groups with ticket access
  765. and would not have included committer_groups and admin_groups.
  766. :type combine: boolean
  767. """
  768. if access not in ["admin", "commit", "ticket"]:
  769. raise pagure.exceptions.AccessLevelNotFound(
  770. "The access level does not exist"
  771. )
  772. if combine:
  773. if access == "admin":
  774. return self.admin_groups
  775. elif access == "commit":
  776. return self.committer_groups
  777. elif access == "ticket":
  778. return self.groups
  779. else:
  780. if access == "admin":
  781. return self.admin_groups
  782. elif access == "commit":
  783. committers = set(self.committer_groups)
  784. admins = set(self.admin_groups)
  785. return list(committers - admins)
  786. elif access == "ticket":
  787. committers = set(self.committer_groups)
  788. admins = set(self.admin_groups)
  789. groups = set(self.groups)
  790. return list(groups - committers - admins)
  791. @property
  792. def access_users(self):
  793. """ Return a dictionary with all user access
  794. """
  795. return {
  796. "admin": self.get_project_users(access="admin", combine=False),
  797. "commit": self.get_project_users(access="commit", combine=False),
  798. "ticket": self.get_project_users(access="ticket", combine=False),
  799. }
  800. @property
  801. def access_users_json(self):
  802. json_access_users = {"owner": [self.user.username]}
  803. for access, users in self.access_users.items():
  804. json_access_users[access] = []
  805. for user in users:
  806. json_access_users[access].append(user.user)
  807. return json_access_users
  808. @property
  809. def access_groups_json(self):
  810. json_access_groups = {}
  811. for access, groups in self.access_groups.items():
  812. json_access_groups[access] = []
  813. for group in groups:
  814. json_access_groups[access].append(group.group_name)
  815. return json_access_groups
  816. @property
  817. def access_groups(self):
  818. """ Return a dictionary with all group access
  819. """
  820. return {
  821. "admin": self.get_project_groups(access="admin", combine=False),
  822. "commit": self.get_project_groups(access="commit", combine=False),
  823. "ticket": self.get_project_groups(access="ticket", combine=False),
  824. }
  825. def lock(self, ltype):
  826. """ Get a SQL lock of type ltype for the current project.
  827. """
  828. return ProjectLocker(self, ltype)
  829. def to_json(self, public=False, api=False):
  830. """ Return a representation of the project as JSON.
  831. """
  832. custom_keys = [[key.name, key.key_type] for key in self.issue_keys]
  833. output = {
  834. "id": self.id,
  835. "name": self.name,
  836. "fullname": self.fullname,
  837. "url_path": self.url_path,
  838. "description": self.description,
  839. "namespace": self.namespace,
  840. "parent": self.parent.to_json(public=public, api=api)
  841. if self.parent
  842. else None,
  843. "date_created": arrow_ts(self.date_created),
  844. "date_modified": arrow_ts(self.date_modified),
  845. "user": self.user.to_json(public=public),
  846. "access_users": self.access_users_json,
  847. "access_groups": self.access_groups_json,
  848. "tags": self.tags_text,
  849. "priorities": self.priorities,
  850. "custom_keys": custom_keys,
  851. "close_status": self.close_status,
  852. "milestones": self.milestones,
  853. }
  854. if not api and not public:
  855. output["settings"] = self.settings
  856. return output
  857. class ProjectLock(BASE):
  858. """ Table used to define project-specific locks.
  859. Table -- project_locks
  860. """
  861. __tablename__ = "project_locks"
  862. project_id = sa.Column(
  863. sa.Integer,
  864. sa.ForeignKey("projects.id", onupdate="CASCADE", ondelete="CASCADE"),
  865. nullable=False,
  866. primary_key=True,
  867. )
  868. lock_type = sa.Column(
  869. sa.Enum(
  870. "WORKER", "WORKER_TICKET", "WORKER_REQUEST", name="lock_type_enum"
  871. ),
  872. nullable=False,
  873. primary_key=True,
  874. )
  875. class ProjectLocker(object):
  876. """ This is used as a context manager to lock a project.
  877. This is used as a context manager to make it very explicit when we unlock
  878. the project, and so that we unlock even if an exception occurs.
  879. """
  880. def __init__(self, project, ltype):
  881. self.session = None
  882. self.lock = None
  883. self.project_id = project.id
  884. self.ltype = ltype
  885. def __enter__(self):
  886. from pagure.lib.query import create_session
  887. self.session = create_session()
  888. _log.info("Grabbing lock for %d", self.project_id)
  889. query = (
  890. self.session.query(ProjectLock)
  891. .filter(ProjectLock.project_id == self.project_id)
  892. .filter(ProjectLock.lock_type == self.ltype)
  893. .with_for_update(nowait=False, read=False)
  894. )
  895. try:
  896. self.lock = query.one()
  897. except Exception:
  898. pl = ProjectLock(project_id=self.project_id, lock_type=self.ltype)
  899. self.session.add(pl)
  900. self.session.commit()
  901. self.lock = query.one()
  902. assert self.lock is not None
  903. _log.info("Got lock for %d: %s", self.project_id, self.lock)
  904. def __exit__(self, *exargs):
  905. _log.info("Releasing lock for %d", self.project_id)
  906. self.session.remove()
  907. _log.info("Released lock for %d", self.project_id)
  908. class ProjectUser(BASE):
  909. """ Stores the user of a projects.
  910. Table -- user_projects
  911. """
  912. __tablename__ = "user_projects"
  913. __table_args__ = (sa.UniqueConstraint("project_id", "user_id", "access"),)
  914. id = sa.Column(sa.Integer, primary_key=True)
  915. project_id = sa.Column(
  916. sa.Integer,
  917. sa.ForeignKey("projects.id", onupdate="CASCADE"),
  918. nullable=False,
  919. )
  920. user_id = sa.Column(
  921. sa.Integer,
  922. sa.ForeignKey("users.id", onupdate="CASCADE"),
  923. nullable=False,
  924. index=True,
  925. )
  926. access = sa.Column(
  927. sa.String(255),
  928. sa.ForeignKey(
  929. "access_levels.access", onupdate="CASCADE", ondelete="CASCADE"
  930. ),
  931. nullable=False,
  932. )
  933. project = relation(
  934. "Project",
  935. remote_side=[Project.id],
  936. backref=backref(
  937. "user_projects", cascade="delete,delete-orphan", single_parent=True
  938. ),
  939. )
  940. user = relation("User", backref="user_projects")
  941. class SSHKey(BASE):
  942. """ Stores information about SSH keys.
  943. Every instance needs to either have user_id set (SSH key for a specific
  944. user) or project_id ("deploy key" for a specific project).
  945. Table -- sshkeys
  946. """
  947. __tablename__ = "sshkeys"
  948. id = sa.Column(sa.Integer, primary_key=True)
  949. project_id = sa.Column(
  950. sa.Integer,
  951. sa.ForeignKey("projects.id", onupdate="CASCADE", ondelete="CASCADE"),
  952. nullable=True,
  953. index=True,
  954. )
  955. pushaccess = sa.Column(sa.Boolean, nullable=False, default=False)
  956. user_id = sa.Column(
  957. sa.Integer,
  958. sa.ForeignKey("users.id", onupdate="CASCADE"),
  959. nullable=True,
  960. index=True,
  961. )
  962. public_ssh_key = sa.Column(sa.Text, nullable=False)
  963. ssh_short_key = sa.Column(sa.Text, nullable=False)
  964. ssh_search_key = sa.Column(
  965. sa.String(length=60), nullable=False, index=True, unique=True
  966. )
  967. creator_user_id = sa.Column(
  968. sa.Integer,
  969. sa.ForeignKey("users.id", onupdate="CASCADE"),
  970. nullable=False,
  971. index=True,
  972. )
  973. date_created = sa.Column(
  974. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  975. )
  976. # Validations
  977. # These two validators are intended to make sure an SSHKey is either
  978. # assigned to a Project or a User, but not both.
  979. @validates("project_id")
  980. def validate_project_id(self, key, value):
  981. """ Validates that user_id is not set. """
  982. if self.user_id is not None:
  983. raise ValueError("SSHKey can't have both project and user")
  984. return value
  985. @validates("user_id")
  986. def validate_user_id(self, key, value):
  987. """ Validates that project_id is not set. """
  988. if self.project_id is not None:
  989. raise ValueError("SSHKey can't have both user and project")
  990. return value
  991. # Relations
  992. project = relation(
  993. "Project",
  994. foreign_keys=[project_id],
  995. remote_side=[Project.id],
  996. backref=backref(
  997. "deploykeys", cascade="delete, delete-orphan", single_parent=True
  998. ),
  999. )
  1000. user = relation(
  1001. "User",
  1002. foreign_keys=[user_id],
  1003. remote_side=[User.id],
  1004. backref=backref(
  1005. "sshkeys", cascade="delete, delete-orphan", single_parent=True
  1006. ),
  1007. )
  1008. creator_user = relation(
  1009. "User", foreign_keys=[creator_user_id], remote_side=[User.id]
  1010. )
  1011. class Issue(BASE):
  1012. """ Stores the issues reported on a project.
  1013. Table -- issues
  1014. """
  1015. __tablename__ = "issues"
  1016. id = sa.Column(sa.Integer, primary_key=True)
  1017. uid = sa.Column(sa.String(32), unique=True, nullable=False)
  1018. project_id = sa.Column(
  1019. sa.Integer,
  1020. sa.ForeignKey("projects.id", onupdate="CASCADE"),
  1021. primary_key=True,
  1022. )
  1023. title = sa.Column(sa.Text, nullable=False)
  1024. content = sa.Column(sa.Text(), nullable=False)
  1025. user_id = sa.Column(
  1026. sa.Integer,
  1027. sa.ForeignKey("users.id", onupdate="CASCADE"),
  1028. nullable=False,
  1029. index=True,
  1030. )
  1031. assignee_id = sa.Column(
  1032. sa.Integer,
  1033. sa.ForeignKey("users.id", onupdate="CASCADE"),
  1034. nullable=True,
  1035. index=True,
  1036. )
  1037. status = sa.Column(
  1038. sa.String(255),
  1039. sa.ForeignKey("status_issue.status", onupdate="CASCADE"),
  1040. default="Open",
  1041. nullable=False,
  1042. )
  1043. private = sa.Column(sa.Boolean, nullable=False, default=False)
  1044. priority = sa.Column(sa.Integer, nullable=True, default=None)
  1045. milestone = sa.Column(sa.String(255), nullable=True, default=None)
  1046. close_status = sa.Column(sa.Text, nullable=True)
  1047. date_created = sa.Column(
  1048. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  1049. )
  1050. last_updated = sa.Column(
  1051. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  1052. )
  1053. closed_at = sa.Column(sa.DateTime, nullable=True)
  1054. closed_by_id = sa.Column(
  1055. sa.Integer,
  1056. sa.ForeignKey("users.id", onupdate="CASCADE"),
  1057. nullable=True,
  1058. )
  1059. project = relation(
  1060. "Project",
  1061. foreign_keys=[project_id],
  1062. remote_side=[Project.id],
  1063. backref=backref("issues", cascade="delete, delete-orphan"),
  1064. single_parent=True,
  1065. )
  1066. user = relation(
  1067. "User", foreign_keys=[user_id], remote_side=[User.id], backref="issues"
  1068. )
  1069. assignee = relation(
  1070. "User",
  1071. foreign_keys=[assignee_id],
  1072. remote_side=[User.id],
  1073. backref="assigned_issues",
  1074. )
  1075. parents = relation(
  1076. "Issue",
  1077. secondary="issue_to_issue",
  1078. primaryjoin="issues.c.uid==issue_to_issue.c.child_issue_id",
  1079. secondaryjoin="issue_to_issue.c.parent_issue_id==issues.c.uid",
  1080. backref="children",
  1081. )
  1082. tags = relation(
  1083. "TagColored",
  1084. secondary="tags_issues_colored",
  1085. primaryjoin="issues.c.uid==tags_issues_colored.c.issue_uid",
  1086. secondaryjoin="tags_issues_colored.c.tag_id==tags_colored.c.id",
  1087. viewonly=True,
  1088. )
  1089. closed_by = relation(
  1090. "User",
  1091. foreign_keys=[closed_by_id],
  1092. remote_side=[User.id],
  1093. backref="closed_issues",
  1094. )
  1095. def __repr__(self):
  1096. return "Issue(%s, project:%s, user:%s, title:%s)" % (
  1097. self.id,
  1098. self.project.name,
  1099. self.user.user,
  1100. self.title,
  1101. )
  1102. @property
  1103. def attachments(self):
  1104. """ Return a list of attachment tuples: (LINK, FILENAME, DISPLAY_NAME,
  1105. DATE) """
  1106. def extract_info(text):
  1107. """ Return a tuple containing the link, file name, and the
  1108. "display" file name from the markdown attachment link """
  1109. pattern_md = re.compile(r"^\[\!(.*)\]")
  1110. pattern_link = re.compile(r"\(([^)]+)\)")
  1111. pattern_file = re.compile(r"\[([^]]+)\]")
  1112. try:
  1113. md_link = pattern_md.search(text).group(1)
  1114. link = pattern_link.search(md_link).group(1)
  1115. filename = pattern_file.search(md_link).group(1)
  1116. if md_link is None or link is None or filename is None:
  1117. # No match, return the original string
  1118. return (text, text, text)
  1119. if len(filename) > 50:
  1120. # File name is too long to display, truncate it.
  1121. display_name = filename[:50] + "..."
  1122. else:
  1123. display_name = filename
  1124. except AttributeError:
  1125. # Search failed, return the original string
  1126. return (text, text, text)
  1127. return (link, filename, display_name)
  1128. attachments = []
  1129. if self.content:
  1130. # Check the initial issue description for attachments
  1131. lines = self.content.split("\n")
  1132. for line in lines:
  1133. if line and line != "" and line.startswith("[!["):
  1134. link, filename, display_name = extract_info(line)
  1135. attachments.append(
  1136. (
  1137. link,
  1138. filename,
  1139. display_name,
  1140. self.date_created.strftime("%Y-%m-%d %H:%M:%S"),
  1141. None,
  1142. )
  1143. )
  1144. if self.comments:
  1145. # Check the comments for attachments
  1146. for comment in self.comments:
  1147. if comment.id == 0:
  1148. comment_text = comment.content
  1149. else:
  1150. comment_text = comment.comment
  1151. lines = comment_text.split("\n")
  1152. for line in lines:
  1153. if line and line != "" and line.startswith("[!["):
  1154. link, filename, display_name = extract_info(line)
  1155. attachments.append(
  1156. (
  1157. link,
  1158. filename,
  1159. display_name,
  1160. comment.date_created.strftime(
  1161. "%Y-%m-%d %H:%M:%S"
  1162. ),
  1163. "%s" % comment.id,
  1164. )
  1165. )
  1166. return attachments
  1167. @property
  1168. def isa(self):
  1169. """ A string to allow finding out that this is an issue. """
  1170. return "issue"
  1171. @property
  1172. def repotype(self):
  1173. """ A string returning the repotype for repopath() calls. """
  1174. return "tickets"
  1175. @property
  1176. def mail_id(self):
  1177. """ Return a unique reprensetation of the issue as string that
  1178. can be used when sending emails.
  1179. """
  1180. return "%s-ticket-%s" % (self.project.name, self.uid)
  1181. @property
  1182. def tags_text(self):
  1183. """ Return the list of tags in a simple text form. """
  1184. return [tag.tag for tag in self.tags]
  1185. @property
  1186. def depending_text(self):
  1187. """ Return the list of issue this issue depends on in simple text. """
  1188. return [issue.id for issue in self.parents]
  1189. @property
  1190. def blocking_text(self):
  1191. """ Return the list of issue this issue blocks on in simple text. """
  1192. return [issue.id for issue in self.children]
  1193. @property
  1194. def user_comments(self):
  1195. """ Return user comments only, filter it from notifications
  1196. """
  1197. return [
  1198. comment for comment in self.comments if not comment.notification
  1199. ]
  1200. @property
  1201. def sortable_priority(self):
  1202. """ Return an empty string if no priority is set allowing issues to
  1203. be sorted using this attribute. """
  1204. return self.priority if self.priority else ""
  1205. def to_json(self, public=False, with_comments=True, with_project=False):
  1206. """ Returns a dictionary representation of the issue.
  1207. """
  1208. custom_fields = [
  1209. dict(
  1210. name=field.key.name,
  1211. key_type=field.key.key_type,
  1212. value=field.value,
  1213. key_data=field.key.key_data,
  1214. )
  1215. for field in self.other_fields
  1216. ]
  1217. output = {
  1218. "id": self.id,
  1219. "title": self.title,
  1220. "content": self.content,
  1221. "status": self.status,
  1222. "close_status": self.close_status,
  1223. "date_created": arrow_ts(self.date_created),
  1224. "last_updated": arrow_ts(self.last_updated),
  1225. "closed_at": arrow_ts(self.closed_at) if self.closed_at else None,
  1226. "user": self.user.to_json(public=public),
  1227. "private": self.private,
  1228. "tags": self.tags_text,
  1229. "depends": ["%s" % item for item in self.depending_text],
  1230. "blocks": ["%s" % item for item in self.blocking_text],
  1231. "assignee": self.assignee.to_json(public=public)
  1232. if self.assignee
  1233. else None,
  1234. "priority": self.priority,
  1235. "milestone": self.milestone,
  1236. "custom_fields": custom_fields,
  1237. "closed_by": self.closed_by.to_json(public=public)
  1238. if self.closed_by
  1239. else None,
  1240. }
  1241. comments = []
  1242. if with_comments:
  1243. for comment in self.comments:
  1244. comments.append(comment.to_json(public=public))
  1245. output["comments"] = comments
  1246. if with_project:
  1247. output["project"] = self.project.to_json(public=public, api=True)
  1248. return output
  1249. class IssueToIssue(BASE):
  1250. """ Stores the parent/child relationship between two issues.
  1251. Table -- issue_to_issue
  1252. """
  1253. __tablename__ = "issue_to_issue"
  1254. parent_issue_id = sa.Column(
  1255. sa.String(32),
  1256. sa.ForeignKey("issues.uid", ondelete="CASCADE", onupdate="CASCADE"),
  1257. primary_key=True,
  1258. )
  1259. child_issue_id = sa.Column(
  1260. sa.String(32),
  1261. sa.ForeignKey("issues.uid", ondelete="CASCADE", onupdate="CASCADE"),
  1262. primary_key=True,
  1263. )
  1264. class PrToIssue(BASE):
  1265. """ Stores the associations between issues and pull-requests.
  1266. Table -- pr_to_issue
  1267. """
  1268. __tablename__ = "pr_to_issue"
  1269. pull_request_uid = sa.Column(
  1270. sa.String(32),
  1271. sa.ForeignKey(
  1272. "pull_requests.uid", ondelete="CASCADE", onupdate="CASCADE"
  1273. ),
  1274. primary_key=True,
  1275. )
  1276. issue_uid = sa.Column(
  1277. sa.String(32),
  1278. sa.ForeignKey("issues.uid", ondelete="CASCADE", onupdate="CASCADE"),
  1279. primary_key=True,
  1280. )
  1281. class IssueComment(BASE):
  1282. """ Stores the comments made on a commit/file.
  1283. Table -- issue_comments
  1284. """
  1285. __tablename__ = "issue_comments"
  1286. id = sa.Column(sa.Integer, primary_key=True)
  1287. issue_uid = sa.Column(
  1288. sa.String(32),
  1289. sa.ForeignKey("issues.uid", ondelete="CASCADE", onupdate="CASCADE"),
  1290. index=True,
  1291. )
  1292. comment = sa.Column(sa.Text(), nullable=False)
  1293. parent_id = sa.Column(
  1294. sa.Integer,
  1295. sa.ForeignKey("issue_comments.id", onupdate="CASCADE"),
  1296. nullable=True,
  1297. )
  1298. user_id = sa.Column(
  1299. sa.Integer,
  1300. sa.ForeignKey("users.id", onupdate="CASCADE"),
  1301. nullable=False,
  1302. index=True,
  1303. )
  1304. notification = sa.Column(sa.Boolean, default=False, nullable=False)
  1305. edited_on = sa.Column(sa.DateTime, nullable=True)
  1306. editor_id = sa.Column(
  1307. sa.Integer,
  1308. sa.ForeignKey("users.id", onupdate="CASCADE"),
  1309. nullable=True,
  1310. )
  1311. date_created = sa.Column(
  1312. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  1313. )
  1314. issue = relation(
  1315. "Issue",
  1316. foreign_keys=[issue_uid],
  1317. remote_side=[Issue.uid],
  1318. backref=backref(
  1319. "comments",
  1320. cascade="delete, delete-orphan",
  1321. order_by=str("IssueComment.date_created"),
  1322. ),
  1323. )
  1324. user = relation(
  1325. "User",
  1326. foreign_keys=[user_id],
  1327. remote_side=[User.id],
  1328. backref="comment_issues",
  1329. )
  1330. editor = relation("User", foreign_keys=[editor_id], remote_side=[User.id])
  1331. _reactions = sa.Column(sa.Text, nullable=True)
  1332. @property
  1333. def mail_id(self):
  1334. """ Return a unique reprensetation of the issue as string that
  1335. can be used when sending emails.
  1336. """
  1337. return "%s-ticket-%s-%s" % (
  1338. self.issue.project.name,
  1339. self.issue.uid,
  1340. self.id,
  1341. )
  1342. @property
  1343. def parent(self):
  1344. """ Return the parent, in this case the issue object. """
  1345. return self.issue
  1346. @property
  1347. def reactions(self):
  1348. """ Return the reactions stored as a string in the database parsed as
  1349. an actual dict object.
  1350. """
  1351. if self._reactions:
  1352. return json.loads(self._reactions)
  1353. return {}
  1354. @reactions.setter
  1355. def reactions(self, reactions):
  1356. """ Ensures that reactions are properly saved. """
  1357. self._reactions = json.dumps(reactions)
  1358. def to_json(self, public=False):
  1359. """ Returns a dictionary representation of the issue.
  1360. """
  1361. output = {
  1362. "id": self.id,
  1363. "comment": self.comment,
  1364. "parent": self.parent_id,
  1365. "date_created": arrow_ts(self.date_created),
  1366. "user": self.user.to_json(public=public),
  1367. "edited_on": arrow_ts(self.edited_on) if self.edited_on else None,
  1368. "editor": self.editor.to_json(public=public)
  1369. if self.editor_id
  1370. else None,
  1371. "notification": self.notification,
  1372. "reactions": self.reactions,
  1373. }
  1374. return output
  1375. class IssueKeys(BASE):
  1376. """ Stores the custom keys a project can use on issues.
  1377. Table -- issue_keys
  1378. """
  1379. __tablename__ = "issue_keys"
  1380. id = sa.Column(sa.Integer, primary_key=True)
  1381. project_id = sa.Column(
  1382. sa.Integer,
  1383. sa.ForeignKey("projects.id", onupdate="CASCADE", ondelete="CASCADE"),
  1384. nullable=False,
  1385. )
  1386. name = sa.Column(sa.String(255), nullable=False)
  1387. key_type = sa.Column(sa.String(255), nullable=False)
  1388. key_data = sa.Column(sa.Text())
  1389. key_notify = sa.Column(sa.Boolean, default=False, nullable=False)
  1390. __table_args__ = (sa.UniqueConstraint("project_id", "name"),)
  1391. project = relation(
  1392. "Project",
  1393. foreign_keys=[project_id],
  1394. remote_side=[Project.id],
  1395. backref=backref(
  1396. "issue_keys", cascade="delete, delete-orphan", single_parent=True
  1397. ),
  1398. )
  1399. def __lt__(self, other):
  1400. if hasattr(other, "name"):
  1401. return self.name.__lt__(other.name)
  1402. @property
  1403. def data(self):
  1404. """ Return the list of items """
  1405. if self.key_data:
  1406. return json.loads(self.key_data)
  1407. else:
  1408. return None
  1409. @data.setter
  1410. def data(self, data_obj):
  1411. """ Store the list data in JSON. """
  1412. if data_obj is None:
  1413. self.key_data = None
  1414. else:
  1415. self.key_data = json.dumps(data_obj)
  1416. class IssueValues(BASE):
  1417. """ Stores the values of the custom keys set by project on issues.
  1418. Table -- issue_values
  1419. """
  1420. __tablename__ = "issue_values"
  1421. key_id = sa.Column(
  1422. sa.Integer,
  1423. sa.ForeignKey("issue_keys.id", ondelete="CASCADE", onupdate="CASCADE"),
  1424. primary_key=True,
  1425. )
  1426. issue_uid = sa.Column(
  1427. sa.String(32),
  1428. sa.ForeignKey("issues.uid", ondelete="CASCADE", onupdate="CASCADE"),
  1429. primary_key=True,
  1430. )
  1431. value = sa.Column(sa.Text(), nullable=False)
  1432. issue = relation(
  1433. "Issue",
  1434. foreign_keys=[issue_uid],
  1435. remote_side=[Issue.uid],
  1436. backref=backref(
  1437. "other_fields", cascade="delete, delete-orphan", single_parent=True
  1438. ),
  1439. )
  1440. key = relation(
  1441. "IssueKeys",
  1442. foreign_keys=[key_id],
  1443. remote_side=[IssueKeys.id],
  1444. backref=backref("values", cascade="delete, delete-orphan"),
  1445. )
  1446. class Tag(BASE):
  1447. """ Stores the tags.
  1448. Table -- tags
  1449. """
  1450. __tablename__ = "tags"
  1451. tag = sa.Column(sa.String(255), primary_key=True)
  1452. date_created = sa.Column(
  1453. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  1454. )
  1455. class TagIssue(BASE):
  1456. """ Stores the tag associated with an issue.
  1457. Table -- tags_issues
  1458. """
  1459. __tablename__ = "tags_issues"
  1460. tag = sa.Column(
  1461. sa.String(255),
  1462. sa.ForeignKey("tags.tag", ondelete="CASCADE", onupdate="CASCADE"),
  1463. primary_key=True,
  1464. )
  1465. issue_uid = sa.Column(
  1466. sa.String(32),
  1467. sa.ForeignKey("issues.uid", ondelete="CASCADE", onupdate="CASCADE"),
  1468. primary_key=True,
  1469. )
  1470. date_created = sa.Column(
  1471. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  1472. )
  1473. issue = relation(
  1474. "Issue",
  1475. foreign_keys=[issue_uid],
  1476. remote_side=[Issue.uid],
  1477. backref=backref(
  1478. "old_tags", cascade="delete, delete-orphan", single_parent=True
  1479. ),
  1480. )
  1481. def __repr__(self):
  1482. return "TagIssue(issue:%s, tag:%s)" % (self.issue.id, self.tag)
  1483. class TagColored(BASE):
  1484. """ Stores the colored tags.
  1485. Table -- tags_colored
  1486. """
  1487. __tablename__ = "tags_colored"
  1488. id = sa.Column(sa.Integer, primary_key=True)
  1489. tag = sa.Column(sa.String(255), nullable=False)
  1490. tag_description = sa.Column(sa.String(255), default="")
  1491. project_id = sa.Column(
  1492. sa.Integer,
  1493. sa.ForeignKey("projects.id", ondelete="CASCADE", onupdate="CASCADE"),
  1494. nullable=False,
  1495. )
  1496. tag_color = sa.Column(sa.String(25), default="DeepSkyBlue")
  1497. date_created = sa.Column(
  1498. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  1499. )
  1500. __table_args__ = (sa.UniqueConstraint("project_id", "tag"),)
  1501. project = relation(
  1502. "Project",
  1503. foreign_keys=[project_id],
  1504. remote_side=[Project.id],
  1505. backref=backref(
  1506. "tags_colored", cascade="delete,delete-orphan", single_parent=True
  1507. ),
  1508. )
  1509. def __repr__(self):
  1510. return "TagColored(id: %s, tag:%s, tag_description:%s, color:%s)" % (
  1511. self.id,
  1512. self.tag,
  1513. self.tag_description,
  1514. self.tag_color,
  1515. )
  1516. class TagIssueColored(BASE):
  1517. """ Stores the colored tag associated with an issue.
  1518. Table -- tags_issues_colored
  1519. """
  1520. __tablename__ = "tags_issues_colored"
  1521. tag_id = sa.Column(
  1522. sa.Integer,
  1523. sa.ForeignKey(
  1524. "tags_colored.id", ondelete="CASCADE", onupdate="CASCADE"
  1525. ),
  1526. primary_key=True,
  1527. )
  1528. issue_uid = sa.Column(
  1529. sa.String(32),
  1530. sa.ForeignKey("issues.uid", ondelete="CASCADE", onupdate="CASCADE"),
  1531. primary_key=True,
  1532. )
  1533. date_created = sa.Column(
  1534. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  1535. )
  1536. issue = relation(
  1537. "Issue",
  1538. foreign_keys=[issue_uid],
  1539. remote_side=[Issue.uid],
  1540. backref=backref(
  1541. "tags_issues_colored", cascade="delete, delete-orphan"
  1542. ),
  1543. )
  1544. tag = relation(
  1545. "TagColored", foreign_keys=[tag_id], remote_side=[TagColored.id]
  1546. )
  1547. def __repr__(self):
  1548. return "TagIssueColored(issue:%s, tag:%s, project:%s)" % (
  1549. self.issue.id,
  1550. self.tag.tag,
  1551. self.tag.project.fullname,
  1552. )
  1553. class TagProject(BASE):
  1554. """ Stores the tag associated with a project.
  1555. Table -- tags_projects
  1556. """
  1557. __tablename__ = "tags_projects"
  1558. tag = sa.Column(
  1559. sa.String(255),
  1560. sa.ForeignKey("tags.tag", ondelete="CASCADE", onupdate="CASCADE"),
  1561. primary_key=True,
  1562. )
  1563. project_id = sa.Column(
  1564. sa.Integer,
  1565. sa.ForeignKey("projects.id", ondelete="CASCADE", onupdate="CASCADE"),
  1566. primary_key=True,
  1567. )
  1568. date_created = sa.Column(
  1569. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  1570. )
  1571. project = relation(
  1572. "Project",
  1573. foreign_keys=[project_id],
  1574. remote_side=[Project.id],
  1575. backref=backref(
  1576. "tags", cascade="delete, delete-orphan", single_parent=True
  1577. ),
  1578. )
  1579. def __repr__(self):
  1580. return "TagProject(project:%s, tag:%s)" % (
  1581. self.project.fullname,
  1582. self.tag,
  1583. )
  1584. class PullRequest(BASE):
  1585. """ Stores the pull requests created on a project.
  1586. Table -- pull_requests
  1587. """
  1588. __tablename__ = "pull_requests"
  1589. id = sa.Column(sa.Integer, primary_key=True)
  1590. uid = sa.Column(sa.String(32), unique=True, nullable=False)
  1591. title = sa.Column(sa.Text, nullable=False)
  1592. project_id = sa.Column(
  1593. sa.Integer,
  1594. sa.ForeignKey("projects.id", ondelete="CASCADE", onupdate="CASCADE"),
  1595. primary_key=True,
  1596. )
  1597. branch = sa.Column(sa.Text(), nullable=False)
  1598. project_id_from = sa.Column(
  1599. sa.Integer,
  1600. sa.ForeignKey("projects.id", ondelete="SET NULL", onupdate="CASCADE"),
  1601. nullable=True,
  1602. )
  1603. remote_git = sa.Column(sa.Text(), nullable=True)
  1604. branch_from = sa.Column(sa.Text(), nullable=False)
  1605. commit_start = sa.Column(sa.Text(), nullable=True)
  1606. commit_stop = sa.Column(sa.Text(), nullable=True)
  1607. initial_comment = sa.Column(sa.Text(), nullable=True)
  1608. user_id = sa.Column(
  1609. sa.Integer,
  1610. sa.ForeignKey("users.id", onupdate="CASCADE"),
  1611. nullable=False,
  1612. index=True,
  1613. )
  1614. assignee_id = sa.Column(
  1615. sa.Integer,
  1616. sa.ForeignKey("users.id", onupdate="CASCADE"),
  1617. nullable=True,
  1618. index=True,
  1619. )
  1620. merge_status = sa.Column(
  1621. sa.Enum(
  1622. "NO_CHANGE",
  1623. "FFORWARD",
  1624. "CONFLICTS",
  1625. "MERGE",
  1626. name="merge_status_enum",
  1627. ),
  1628. nullable=True,
  1629. )
  1630. # While present this column isn't used anywhere yet
  1631. private = sa.Column(sa.Boolean, nullable=False, default=False)
  1632. status = sa.Column(
  1633. sa.String(255),
  1634. sa.ForeignKey("status_pull_requests.status", onupdate="CASCADE"),
  1635. default="Open",
  1636. nullable=False,
  1637. )
  1638. closed_by_id = sa.Column(
  1639. sa.Integer,
  1640. sa.ForeignKey("users.id", onupdate="CASCADE"),
  1641. nullable=True,
  1642. )
  1643. closed_at = sa.Column(sa.DateTime, nullable=True)
  1644. date_created = sa.Column(
  1645. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  1646. )
  1647. updated_on = sa.Column(
  1648. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  1649. )
  1650. last_updated = sa.Column(
  1651. sa.DateTime,
  1652. nullable=False,
  1653. default=datetime.datetime.utcnow,
  1654. onupdate=datetime.datetime.utcnow,
  1655. )
  1656. project = relation(
  1657. "Project",
  1658. foreign_keys=[project_id],
  1659. remote_side=[Project.id],
  1660. backref=backref("requests", cascade="delete, delete-orphan"),
  1661. single_parent=True,
  1662. )
  1663. project_from = relation(
  1664. "Project", foreign_keys=[project_id_from], remote_side=[Project.id]
  1665. )
  1666. user = relation(
  1667. "User",
  1668. foreign_keys=[user_id],
  1669. remote_side=[User.id],
  1670. backref="pull_requests",
  1671. )
  1672. assignee = relation(
  1673. "User",
  1674. foreign_keys=[assignee_id],
  1675. remote_side=[User.id],
  1676. backref="assigned_requests",
  1677. )
  1678. closed_by = relation(
  1679. "User",
  1680. foreign_keys=[closed_by_id],
  1681. remote_side=[User.id],
  1682. backref="closed_requests",
  1683. )
  1684. tags = relation(
  1685. "TagColored",
  1686. secondary="tags_pull_requests",
  1687. primaryjoin="pull_requests.c.uid==tags_pull_requests.c.request_uid",
  1688. secondaryjoin="tags_pull_requests.c.tag_id==tags_colored.c.id",
  1689. viewonly=True,
  1690. )
  1691. related_issues = relation(
  1692. "Issue",
  1693. secondary="pr_to_issue",
  1694. primaryjoin="pull_requests.c.uid==pr_to_issue.c.pull_request_uid",
  1695. secondaryjoin="pr_to_issue.c.issue_uid==issues.c.uid",
  1696. backref=backref(
  1697. "related_prs", order_by=str("pull_requests.c.id.desc()")
  1698. ),
  1699. )
  1700. def __repr__(self):
  1701. return "PullRequest(%s, project:%s, user:%s, title:%s)" % (
  1702. self.id,
  1703. self.project.name,
  1704. self.user.user,
  1705. self.title,
  1706. )
  1707. @property
  1708. def isa(self):
  1709. """ A string to allow finding out that this is an pull-request. """
  1710. return "pull-request"
  1711. @property
  1712. def repotype(self):
  1713. """ A string returning the repotype for repopath() calls. """
  1714. return "requests"
  1715. @property
  1716. def mail_id(self):
  1717. """ Return a unique reprensetation of the issue as string that
  1718. can be used when sending emails.
  1719. """
  1720. return "%s-pull-request-%s" % (self.project.name, self.uid)
  1721. @property
  1722. def tags_text(self):
  1723. """ Return the list of tags in a simple text form. """
  1724. return [tag.tag for tag in self.tags]
  1725. @property
  1726. def discussion(self):
  1727. """ Return the list of comments related to the pull-request itself,
  1728. ie: not related to a specific commit.
  1729. """
  1730. return [comment for comment in self.comments if not comment.commit_id]
  1731. @property
  1732. def flags_stats(self):
  1733. """ Return some stats about the flags associated with this PR.
  1734. """
  1735. flags = self.flags
  1736. flags.reverse()
  1737. # Only keep the last flag from each service
  1738. tmp = {}
  1739. for flag in flags:
  1740. tmp[flag.username] = flag
  1741. output = collections.defaultdict(list)
  1742. for flag in tmp.values():
  1743. output[flag.status].append(flag)
  1744. return output
  1745. @property
  1746. def score(self):
  1747. """ Return the review score of the pull-request by checking the
  1748. number of +1, -1, :thumbup: and :thumbdown: in the comment of the
  1749. pull-request.
  1750. This includes only the main comments not the inline ones.
  1751. An user can only give one +1 and one -1.
  1752. """
  1753. votes = {}
  1754. for comment in self.discussion:
  1755. for word in ["+1", ":thumbsup:"]:
  1756. if word in comment.comment:
  1757. votes[comment.user_id] = 1
  1758. break
  1759. for word in ["-1", ":thumbsdown:"]:
  1760. if word in comment.comment:
  1761. votes[comment.user_id] = -1
  1762. break
  1763. return sum(votes.values())
  1764. @property
  1765. def threshold_reached(self):
  1766. """ Return whether the pull-request has reached the threshold above
  1767. which it is allowed to be merged, if the project requests a minimal
  1768. score on pull-request, otherwise returns None.
  1769. """
  1770. threshold = self.project.settings.get(
  1771. "Minimum_score_to_merge_pull-request", -1
  1772. )
  1773. if threshold is None or threshold < 0:
  1774. return None
  1775. else:
  1776. return int(self.score) >= int(threshold)
  1777. @property
  1778. def remote(self):
  1779. """ Return whether the current PullRequest is a remote pull-request
  1780. or not.
  1781. """
  1782. return self.remote_git is not None
  1783. @property
  1784. def user_comments(self):
  1785. """ Return user comments only, filter it from notifications
  1786. """
  1787. return [
  1788. comment for comment in self.comments if not comment.notification
  1789. ]
  1790. def to_json(self, public=False, api=False, with_comments=True):
  1791. """ Returns a dictionary representation of the pull-request.
  1792. """
  1793. output = {
  1794. "id": self.id,
  1795. "uid": self.uid,
  1796. "title": self.title,
  1797. "branch": self.branch,
  1798. "project": self.project.to_json(public=public, api=api),
  1799. "branch_from": self.branch_from,
  1800. "repo_from": self.project_from.to_json(public=public, api=api)
  1801. if self.project_from
  1802. else None,
  1803. "remote_git": self.remote_git,
  1804. "date_created": arrow_ts(self.date_created),
  1805. "updated_on": arrow_ts(self.updated_on),
  1806. "last_updated": arrow_ts(self.last_updated),
  1807. "closed_at": arrow_ts(self.closed_at) if self.closed_at else None,
  1808. "user": self.user.to_json(public=public),
  1809. "assignee": self.assignee.to_json(public=public)
  1810. if self.assignee
  1811. else None,
  1812. "status": self.status,
  1813. "commit_start": self.commit_start,
  1814. "commit_stop": self.commit_stop,
  1815. "closed_by": self.closed_by.to_json(public=public)
  1816. if self.closed_by
  1817. else None,
  1818. "initial_comment": self.initial_comment,
  1819. "cached_merge_status": self.merge_status or "unknown",
  1820. "threshold_reached": self.threshold_reached,
  1821. }
  1822. comments = []
  1823. if with_comments:
  1824. for comment in self.comments:
  1825. comments.append(comment.to_json(public=public))
  1826. output["comments"] = comments
  1827. return output
  1828. class PullRequestComment(BASE):
  1829. """ Stores the comments made on a pull-request.
  1830. Table -- pull_request_comments
  1831. """
  1832. __tablename__ = "pull_request_comments"
  1833. id = sa.Column(sa.Integer, primary_key=True)
  1834. pull_request_uid = sa.Column(
  1835. sa.String(32),
  1836. sa.ForeignKey(
  1837. "pull_requests.uid", ondelete="CASCADE", onupdate="CASCADE"
  1838. ),
  1839. nullable=False,
  1840. )
  1841. commit_id = sa.Column(sa.String(40), nullable=True, index=True)
  1842. user_id = sa.Column(
  1843. sa.Integer,
  1844. sa.ForeignKey("users.id", onupdate="CASCADE"),
  1845. nullable=False,
  1846. index=True,
  1847. )
  1848. filename = sa.Column(sa.Text, nullable=True)
  1849. line = sa.Column(sa.Integer, nullable=True)
  1850. tree_id = sa.Column(sa.String(40), nullable=True)
  1851. comment = sa.Column(sa.Text(), nullable=False)
  1852. parent_id = sa.Column(
  1853. sa.Integer,
  1854. sa.ForeignKey("pull_request_comments.id", onupdate="CASCADE"),
  1855. nullable=True,
  1856. )
  1857. notification = sa.Column(sa.Boolean, default=False, nullable=False)
  1858. edited_on = sa.Column(sa.DateTime, nullable=True)
  1859. editor_id = sa.Column(
  1860. sa.Integer,
  1861. sa.ForeignKey("users.id", onupdate="CASCADE"),
  1862. nullable=True,
  1863. )
  1864. date_created = sa.Column(
  1865. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  1866. )
  1867. user = relation(
  1868. "User",
  1869. foreign_keys=[user_id],
  1870. remote_side=[User.id],
  1871. backref=backref(
  1872. "pull_request_comments",
  1873. order_by=str("PullRequestComment.date_created"),
  1874. ),
  1875. )
  1876. pull_request = relation(
  1877. "PullRequest",
  1878. backref=backref(
  1879. "comments",
  1880. cascade="delete, delete-orphan",
  1881. order_by=str("PullRequestComment.date_created"),
  1882. ),
  1883. foreign_keys=[pull_request_uid],
  1884. remote_side=[PullRequest.uid],
  1885. )
  1886. editor = relation("User", foreign_keys=[editor_id], remote_side=[User.id])
  1887. _reactions = sa.Column(sa.Text, nullable=True)
  1888. @property
  1889. def mail_id(self):
  1890. """ Return a unique representation of the issue as string that
  1891. can be used when sending emails.
  1892. """
  1893. return "%s-pull-request-%s-%s" % (
  1894. self.pull_request.project.name,
  1895. self.pull_request.uid,
  1896. self.id,
  1897. )
  1898. @property
  1899. def parent(self):
  1900. """ Return the parent, in this case the pull_request object. """
  1901. return self.pull_request
  1902. @property
  1903. def reactions(self):
  1904. """ Return the reactions stored as a string in the database parsed as
  1905. an actual dict object.
  1906. """
  1907. if self._reactions:
  1908. return json.loads(self._reactions)
  1909. return {}
  1910. @reactions.setter
  1911. def reactions(self, reactions):
  1912. """ Ensures that reactions are properly saved. """
  1913. self._reactions = json.dumps(reactions)
  1914. def to_json(self, public=False):
  1915. """ Return a dict representation of the pull-request comment. """
  1916. return {
  1917. "id": self.id,
  1918. "commit": self.commit_id,
  1919. "tree": self.tree_id,
  1920. "filename": self.filename,
  1921. "line": self.line,
  1922. "comment": self.comment,
  1923. "parent": self.parent_id,
  1924. "date_created": arrow_ts(self.date_created),
  1925. "user": self.user.to_json(public=public),
  1926. "edited_on": arrow_ts(self.edited_on) if self.edited_on else None,
  1927. "editor": self.editor.to_json(public=public)
  1928. if self.editor_id
  1929. else None,
  1930. "notification": self.notification,
  1931. "reactions": self.reactions,
  1932. }
  1933. class PullRequestFlag(BASE):
  1934. """ Stores the flags attached to a pull-request.
  1935. Table -- pull_request_flags
  1936. """
  1937. __tablename__ = "pull_request_flags"
  1938. id = sa.Column(sa.Integer, primary_key=True)
  1939. uid = sa.Column(sa.String(32), nullable=False)
  1940. pull_request_uid = sa.Column(
  1941. sa.String(32),
  1942. sa.ForeignKey(
  1943. "pull_requests.uid", ondelete="CASCADE", onupdate="CASCADE"
  1944. ),
  1945. nullable=False,
  1946. )
  1947. token_id = sa.Column(
  1948. sa.String(64), sa.ForeignKey("tokens.id"), nullable=True
  1949. )
  1950. status = sa.Column(sa.String(32), nullable=False)
  1951. user_id = sa.Column(
  1952. sa.Integer,
  1953. sa.ForeignKey("users.id", onupdate="CASCADE"),
  1954. nullable=False,
  1955. index=True,
  1956. )
  1957. username = sa.Column(sa.Text(), nullable=False)
  1958. percent = sa.Column(sa.Integer(), nullable=True)
  1959. comment = sa.Column(sa.Text(), nullable=False)
  1960. url = sa.Column(sa.Text(), nullable=False)
  1961. date_created = sa.Column(
  1962. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  1963. )
  1964. date_updated = sa.Column(
  1965. sa.DateTime,
  1966. nullable=False,
  1967. default=datetime.datetime.utcnow,
  1968. onupdate=datetime.datetime.utcnow,
  1969. )
  1970. __table_args__ = (sa.UniqueConstraint("uid", "pull_request_uid"),)
  1971. user = relation(
  1972. "User",
  1973. foreign_keys=[user_id],
  1974. remote_side=[User.id],
  1975. backref=backref(
  1976. "pull_request_flags", order_by=str("PullRequestFlag.date_created")
  1977. ),
  1978. )
  1979. pull_request = relation(
  1980. "PullRequest",
  1981. backref=backref(
  1982. "flags",
  1983. order_by=str("(pull_request_flags.c.date_created).desc()"),
  1984. cascade="delete, delete-orphan",
  1985. ),
  1986. foreign_keys=[pull_request_uid],
  1987. remote_side=[PullRequest.uid],
  1988. )
  1989. @property
  1990. def mail_id(self):
  1991. """ Return a unique representation of the flag as string that
  1992. can be used when sending emails.
  1993. """
  1994. return "%s-pull-request-%s-%s" % (
  1995. self.pull_request.project.name,
  1996. self.pull_request.uid,
  1997. self.id,
  1998. )
  1999. def to_json(self, public=False):
  2000. """ Returns a dictionary representation of the pull-request.
  2001. """
  2002. output = {
  2003. "pull_request_uid": self.pull_request_uid,
  2004. "username": self.username,
  2005. "percent": self.percent,
  2006. "comment": self.comment,
  2007. "status": self.status,
  2008. "url": self.url,
  2009. "date_created": arrow_ts(self.date_created),
  2010. "date_updated": arrow_ts(self.date_updated),
  2011. "user": self.user.to_json(public=public),
  2012. }
  2013. return output
  2014. class CommitFlag(BASE):
  2015. """ Stores the flags attached to a commit.
  2016. Table -- commit_flags
  2017. """
  2018. __tablename__ = "commit_flags"
  2019. id = sa.Column(sa.Integer, primary_key=True)
  2020. commit_hash = sa.Column(sa.String(40), index=True, nullable=False)
  2021. project_id = sa.Column(
  2022. sa.Integer,
  2023. sa.ForeignKey("projects.id", onupdate="CASCADE", ondelete="CASCADE"),
  2024. nullable=False,
  2025. index=True,
  2026. )
  2027. token_id = sa.Column(
  2028. sa.String(64), sa.ForeignKey("tokens.id"), nullable=False
  2029. )
  2030. user_id = sa.Column(
  2031. sa.Integer,
  2032. sa.ForeignKey("users.id", onupdate="CASCADE"),
  2033. nullable=False,
  2034. index=True,
  2035. )
  2036. uid = sa.Column(sa.String(32), nullable=False)
  2037. status = sa.Column(sa.String(32), nullable=False)
  2038. username = sa.Column(sa.Text(), nullable=False)
  2039. percent = sa.Column(sa.Integer(), nullable=True)
  2040. comment = sa.Column(sa.Text(), nullable=False)
  2041. url = sa.Column(sa.Text(), nullable=False)
  2042. date_created = sa.Column(
  2043. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  2044. )
  2045. date_updated = sa.Column(
  2046. sa.DateTime,
  2047. nullable=False,
  2048. default=datetime.datetime.utcnow,
  2049. onupdate=datetime.datetime.utcnow,
  2050. )
  2051. __table_args__ = (sa.UniqueConstraint("commit_hash", "uid"),)
  2052. project = relation(
  2053. "Project",
  2054. foreign_keys=[project_id],
  2055. remote_side=[Project.id],
  2056. backref=backref("commit_flags", cascade="delete, delete-orphan"),
  2057. single_parent=True,
  2058. )
  2059. user = relation(
  2060. "User",
  2061. foreign_keys=[user_id],
  2062. remote_side=[User.id],
  2063. backref=backref(
  2064. "commit_flags", order_by=str("CommitFlag.date_created")
  2065. ),
  2066. )
  2067. @property
  2068. def isa(self):
  2069. """ A string to allow finding out that this is a commit flag. """
  2070. return "commit-flag"
  2071. @property
  2072. def mail_id(self):
  2073. """ Return a unique representation of the flag as string that
  2074. can be used when sending emails.
  2075. """
  2076. return "%s-commit-%s-%s" % (
  2077. self.project.name,
  2078. self.project.id,
  2079. self.id,
  2080. )
  2081. def to_json(self, public=False):
  2082. """ Returns a dictionary representation of the commit flag.
  2083. """
  2084. output = {
  2085. "commit_hash": self.commit_hash,
  2086. "username": self.username,
  2087. "percent": self.percent,
  2088. "comment": self.comment,
  2089. "status": self.status,
  2090. "url": self.url,
  2091. "date_created": arrow_ts(self.date_created),
  2092. "date_updated": arrow_ts(self.date_updated),
  2093. "user": self.user.to_json(public=public),
  2094. }
  2095. return output
  2096. class TagPullRequest(BASE):
  2097. """ Stores the tag associated with an pull-request.
  2098. Table -- tags_pull_requests
  2099. """
  2100. __tablename__ = "tags_pull_requests"
  2101. tag_id = sa.Column(
  2102. sa.Integer,
  2103. sa.ForeignKey(
  2104. "tags_colored.id", ondelete="CASCADE", onupdate="CASCADE"
  2105. ),
  2106. primary_key=True,
  2107. )
  2108. request_uid = sa.Column(
  2109. sa.String(32),
  2110. sa.ForeignKey(
  2111. "pull_requests.uid", ondelete="CASCADE", onupdate="CASCADE"
  2112. ),
  2113. primary_key=True,
  2114. )
  2115. date_created = sa.Column(
  2116. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  2117. )
  2118. pull_request = relation(
  2119. "PullRequest",
  2120. foreign_keys=[request_uid],
  2121. remote_side=[PullRequest.uid],
  2122. backref=backref("tags_pr_colored", cascade="delete, delete-orphan"),
  2123. )
  2124. tag = relation(
  2125. "TagColored", foreign_keys=[tag_id], remote_side=[TagColored.id]
  2126. )
  2127. def __repr__(self):
  2128. return "TagPullRequest(PR:%s, tag:%s)" % (
  2129. self.pull_request.id,
  2130. self.tag,
  2131. )
  2132. class PagureGroupType(BASE):
  2133. """
  2134. A list of the type a group can have definition.
  2135. """
  2136. # names like "Group", "Order" and "User" are reserved words in SQL
  2137. # so we set the name to something safe for SQL
  2138. __tablename__ = "pagure_group_type"
  2139. group_type = sa.Column(sa.String(16), primary_key=True)
  2140. created = sa.Column(
  2141. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  2142. )
  2143. def __repr__(self):
  2144. """ Return a string representation of this object. """
  2145. return "GroupType: %s" % (self.group_type)
  2146. class PagureGroup(BASE):
  2147. """
  2148. An ultra-simple group definition.
  2149. """
  2150. # names like "Group", "Order" and "User" are reserved words in SQL
  2151. # so we set the name to something safe for SQL
  2152. __tablename__ = "pagure_group"
  2153. id = sa.Column(sa.Integer, primary_key=True)
  2154. group_name = sa.Column(sa.String(255), nullable=False, unique=True)
  2155. display_name = sa.Column(sa.String(255), nullable=False, unique=True)
  2156. description = sa.Column(sa.String(255), nullable=True)
  2157. group_type = sa.Column(
  2158. sa.String(16),
  2159. sa.ForeignKey("pagure_group_type.group_type"),
  2160. default="user",
  2161. nullable=False,
  2162. )
  2163. user_id = sa.Column(
  2164. sa.Integer,
  2165. sa.ForeignKey("users.id", onupdate="CASCADE"),
  2166. nullable=False,
  2167. index=True,
  2168. )
  2169. created = sa.Column(
  2170. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  2171. )
  2172. creator = relation(
  2173. "User",
  2174. foreign_keys=[user_id],
  2175. remote_side=[User.id],
  2176. backref=backref("groups_created"),
  2177. )
  2178. def __repr__(self):
  2179. """ Return a string representation of this object. """
  2180. return "Group: %s - name %s" % (self.id, self.group_name)
  2181. def to_json(self, public=False):
  2182. """ Returns a dictionary representation of the pull-request.
  2183. """
  2184. output = {
  2185. "name": self.group_name,
  2186. "display_name": self.display_name,
  2187. "description": self.description,
  2188. "group_type": self.group_type,
  2189. "creator": self.creator.to_json(public=public),
  2190. "date_created": arrow_ts(self.created),
  2191. "members": [user.username for user in self.users],
  2192. }
  2193. return output
  2194. class ProjectGroup(BASE):
  2195. """
  2196. Association table linking the projects table to the pagure_group table.
  2197. This allow linking projects to groups.
  2198. """
  2199. __tablename__ = "projects_groups"
  2200. project_id = sa.Column(
  2201. sa.Integer,
  2202. sa.ForeignKey("projects.id", onupdate="CASCADE", ondelete="CASCADE"),
  2203. primary_key=True,
  2204. )
  2205. group_id = sa.Column(
  2206. sa.Integer, sa.ForeignKey("pagure_group.id"), primary_key=True
  2207. )
  2208. access = sa.Column(
  2209. sa.String(255),
  2210. sa.ForeignKey(
  2211. "access_levels.access", onupdate="CASCADE", ondelete="CASCADE"
  2212. ),
  2213. nullable=False,
  2214. )
  2215. project = relation(
  2216. "Project",
  2217. foreign_keys=[project_id],
  2218. remote_side=[Project.id],
  2219. backref=backref(
  2220. "projects_groups",
  2221. cascade="delete,delete-orphan",
  2222. single_parent=True,
  2223. ),
  2224. )
  2225. group = relation("PagureGroup", backref="projects_groups")
  2226. # Constraints
  2227. __table_args__ = (sa.UniqueConstraint("project_id", "group_id"),)
  2228. class Star(BASE):
  2229. """ Stores users association with the all the projects which
  2230. they have starred
  2231. Table -- star
  2232. """
  2233. __tablename__ = "stargazers"
  2234. __table_args__ = (
  2235. sa.UniqueConstraint(
  2236. "project_id",
  2237. "user_id",
  2238. name="uq_stargazers_project_id_user_id_key",
  2239. ),
  2240. )
  2241. id = sa.Column(sa.Integer, primary_key=True)
  2242. project_id = sa.Column(
  2243. sa.Integer,
  2244. sa.ForeignKey("projects.id", onupdate="CASCADE", ondelete="CASCADE"),
  2245. nullable=False,
  2246. index=True,
  2247. )
  2248. user_id = sa.Column(
  2249. sa.Integer,
  2250. sa.ForeignKey("users.id", onupdate="CASCADE", ondelete="CASCADE"),
  2251. nullable=False,
  2252. )
  2253. user = relation(
  2254. "User",
  2255. foreign_keys=[user_id],
  2256. remote_side=[User.id],
  2257. backref=backref("stars", cascade="delete, delete-orphan"),
  2258. )
  2259. project = relation(
  2260. "Project",
  2261. foreign_keys=[project_id],
  2262. remote_side=[Project.id],
  2263. backref=backref("stargazers", cascade="delete, delete-orphan"),
  2264. )
  2265. class Watcher(BASE):
  2266. """ Stores the user of a projects.
  2267. Table -- watchers
  2268. """
  2269. __tablename__ = "watchers"
  2270. __table_args__ = (sa.UniqueConstraint("project_id", "user_id"),)
  2271. id = sa.Column(sa.Integer, primary_key=True)
  2272. project_id = sa.Column(
  2273. sa.Integer,
  2274. sa.ForeignKey("projects.id", onupdate="CASCADE"),
  2275. nullable=False,
  2276. )
  2277. user_id = sa.Column(
  2278. sa.Integer,
  2279. sa.ForeignKey("users.id", onupdate="CASCADE"),
  2280. nullable=False,
  2281. index=True,
  2282. )
  2283. watch_issues = sa.Column(sa.Boolean, nullable=False, default=False)
  2284. watch_commits = sa.Column(sa.Boolean, nullable=False, default=False)
  2285. user = relation(
  2286. "User",
  2287. foreign_keys=[user_id],
  2288. remote_side=[User.id],
  2289. backref=backref("watchers", cascade="delete, delete-orphan"),
  2290. )
  2291. project = relation(
  2292. "Project",
  2293. foreign_keys=[project_id],
  2294. remote_side=[Project.id],
  2295. backref=backref("watchers", cascade="delete, delete-orphan"),
  2296. )
  2297. @six.python_2_unicode_compatible
  2298. class PagureLog(BASE):
  2299. """
  2300. Log user's actions.
  2301. """
  2302. __tablename__ = "pagure_logs"
  2303. id = sa.Column(sa.Integer, primary_key=True)
  2304. user_id = sa.Column(
  2305. sa.Integer,
  2306. sa.ForeignKey("users.id", onupdate="CASCADE", ondelete="CASCADE"),
  2307. nullable=True,
  2308. index=True,
  2309. )
  2310. user_email = sa.Column(sa.String(255), nullable=True, index=True)
  2311. project_id = sa.Column(
  2312. sa.Integer,
  2313. sa.ForeignKey("projects.id", onupdate="CASCADE", ondelete="CASCADE"),
  2314. nullable=True,
  2315. index=True,
  2316. )
  2317. issue_uid = sa.Column(
  2318. sa.String(32),
  2319. sa.ForeignKey("issues.uid", ondelete="CASCADE", onupdate="CASCADE"),
  2320. nullable=True,
  2321. index=True,
  2322. )
  2323. pull_request_uid = sa.Column(
  2324. sa.String(32),
  2325. sa.ForeignKey(
  2326. "pull_requests.uid", ondelete="CASCADE", onupdate="CASCADE"
  2327. ),
  2328. nullable=True,
  2329. index=True,
  2330. )
  2331. log_type = sa.Column(sa.Text, nullable=False)
  2332. ref_id = sa.Column(sa.Text, nullable=False)
  2333. date = sa.Column(
  2334. sa.Date, nullable=False, default=datetime.datetime.utcnow, index=True
  2335. )
  2336. date_created = sa.Column(
  2337. sa.DateTime,
  2338. nullable=False,
  2339. default=datetime.datetime.utcnow,
  2340. index=True,
  2341. )
  2342. user = relation(
  2343. "User",
  2344. foreign_keys=[user_id],
  2345. remote_side=[User.id],
  2346. backref=backref("logs", cascade="delete, delete-orphan"),
  2347. )
  2348. project = relation(
  2349. "Project",
  2350. foreign_keys=[project_id],
  2351. remote_side=[Project.id],
  2352. backref=backref("logs", cascade="delete, delete-orphan"),
  2353. )
  2354. issue = relation(
  2355. "Issue", foreign_keys=[issue_uid], remote_side=[Issue.uid]
  2356. )
  2357. pull_request = relation(
  2358. "PullRequest",
  2359. foreign_keys=[pull_request_uid],
  2360. remote_side=[PullRequest.uid],
  2361. )
  2362. def to_json(self, public=False):
  2363. """ Returns a dictionary representation of the issue.
  2364. """
  2365. output = {
  2366. "id": self.id,
  2367. "type": self.log_type,
  2368. "ref_id": self.ref_id,
  2369. "date": self.date.strftime("%Y-%m-%d"),
  2370. "date_created": arrow_ts(self.date_created),
  2371. "user": self.user.to_json(public=public),
  2372. }
  2373. return output
  2374. def __str__(self):
  2375. """ A string representation of this log entry. """
  2376. verb = ""
  2377. desc = "%(user)s %(verb)s %(project)s#%(obj_id)s"
  2378. arg = {
  2379. "user": self.user.user if self.user else self.user_email,
  2380. "obj_id": self.ref_id,
  2381. "project": self.project.fullname,
  2382. }
  2383. issue_verb = {
  2384. "created": "created issue",
  2385. "commented": "commented on issue",
  2386. "close": "closed issue",
  2387. "open": "opened issue",
  2388. }
  2389. pr_verb = {
  2390. "created": "created PR",
  2391. "commented": "commented on PR",
  2392. "closed": "closed PR",
  2393. "merged": "merged PR",
  2394. }
  2395. if self.issue and self.log_type in issue_verb:
  2396. verb = issue_verb[self.log_type]
  2397. elif self.pull_request and self.log_type in pr_verb:
  2398. verb = pr_verb[self.log_type]
  2399. elif (
  2400. not self.pull_request
  2401. and not self.issue
  2402. and self.log_type == "created"
  2403. ):
  2404. verb = "created Project"
  2405. desc = "%(user)s %(verb)s %(project)s"
  2406. elif self.log_type == "committed":
  2407. verb = "committed on"
  2408. arg["verb"] = verb
  2409. return desc % arg
  2410. def date_tz(self, tz="UTC"):
  2411. """Returns the date (as a datetime.date()) of this log entry
  2412. in a specified timezone (Olson name as a string). Assumes that
  2413. date_created is aware, or UTC. If tz isn't a valid timezone
  2414. identifier for arrow, just returns the date component of
  2415. date_created.
  2416. """
  2417. try:
  2418. return arrow.get(self.date_created).to(tz).date()
  2419. except arrow.parser.ParserError:
  2420. return self.date_created.date()
  2421. class IssueWatcher(BASE):
  2422. """ Stores the users watching issues.
  2423. Table -- issue_watchers
  2424. """
  2425. __tablename__ = "issue_watchers"
  2426. __table_args__ = (sa.UniqueConstraint("issue_uid", "user_id"),)
  2427. id = sa.Column(sa.Integer, primary_key=True)
  2428. issue_uid = sa.Column(
  2429. sa.String(32),
  2430. sa.ForeignKey("issues.uid", onupdate="CASCADE", ondelete="CASCADE"),
  2431. nullable=False,
  2432. )
  2433. user_id = sa.Column(
  2434. sa.Integer,
  2435. sa.ForeignKey("users.id", onupdate="CASCADE", ondelete="CASCADE"),
  2436. nullable=False,
  2437. index=True,
  2438. )
  2439. watch = sa.Column(sa.Boolean, nullable=False)
  2440. user = relation(
  2441. "User",
  2442. foreign_keys=[user_id],
  2443. remote_side=[User.id],
  2444. backref=backref("issue_watched", cascade="delete, delete-orphan"),
  2445. )
  2446. issue = relation(
  2447. "Issue",
  2448. foreign_keys=[issue_uid],
  2449. remote_side=[Issue.uid],
  2450. backref=backref("watchers", cascade="delete, delete-orphan"),
  2451. )
  2452. class PullRequestWatcher(BASE):
  2453. """ Stores the users watching issues.
  2454. Table -- pull_request_watchers
  2455. """
  2456. __tablename__ = "pull_request_watchers"
  2457. __table_args__ = (sa.UniqueConstraint("pull_request_uid", "user_id"),)
  2458. id = sa.Column(sa.Integer, primary_key=True)
  2459. pull_request_uid = sa.Column(
  2460. sa.String(32),
  2461. sa.ForeignKey(
  2462. "pull_requests.uid", onupdate="CASCADE", ondelete="CASCADE"
  2463. ),
  2464. nullable=False,
  2465. )
  2466. user_id = sa.Column(
  2467. sa.Integer,
  2468. sa.ForeignKey("users.id", onupdate="CASCADE", ondelete="CASCADE"),
  2469. nullable=False,
  2470. index=True,
  2471. )
  2472. watch = sa.Column(sa.Boolean, nullable=False)
  2473. user = relation(
  2474. "User",
  2475. foreign_keys=[user_id],
  2476. remote_side=[User.id],
  2477. backref=backref("pr_watched", cascade="delete, delete-orphan"),
  2478. )
  2479. pull_request = relation(
  2480. "PullRequest",
  2481. foreign_keys=[pull_request_uid],
  2482. remote_side=[PullRequest.uid],
  2483. backref=backref("watchers", cascade="delete, delete-orphan"),
  2484. )
  2485. #
  2486. # Class and tables specific for the API/token access
  2487. #
  2488. class ACL(BASE):
  2489. """
  2490. Table listing all the rights a token can be given
  2491. """
  2492. __tablename__ = "acls"
  2493. id = sa.Column(sa.Integer, primary_key=True)
  2494. name = sa.Column(sa.String(32), unique=True, nullable=False)
  2495. description = sa.Column(sa.Text(), nullable=False)
  2496. created = sa.Column(
  2497. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  2498. )
  2499. def __repr__(self):
  2500. """ Return a string representation of this object. """
  2501. return "ACL: %s - name %s" % (self.id, self.name)
  2502. class Token(BASE):
  2503. """
  2504. Table listing all the tokens per user and per project
  2505. """
  2506. __tablename__ = "tokens"
  2507. id = sa.Column(sa.String(64), primary_key=True)
  2508. user_id = sa.Column(
  2509. sa.Integer,
  2510. sa.ForeignKey("users.id", onupdate="CASCADE"),
  2511. nullable=False,
  2512. index=True,
  2513. )
  2514. project_id = sa.Column(
  2515. sa.Integer,
  2516. sa.ForeignKey("projects.id", onupdate="CASCADE"),
  2517. nullable=True,
  2518. index=True,
  2519. )
  2520. description = sa.Column(sa.Text(), nullable=True)
  2521. expiration = sa.Column(
  2522. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  2523. )
  2524. created = sa.Column(
  2525. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  2526. )
  2527. acls = relation(
  2528. "ACL",
  2529. secondary="tokens_acls",
  2530. primaryjoin="tokens.c.id==tokens_acls.c.token_id",
  2531. secondaryjoin="acls.c.id==tokens_acls.c.acl_id",
  2532. )
  2533. user = relation(
  2534. "User",
  2535. backref=backref(
  2536. "tokens",
  2537. cascade="delete, delete-orphan",
  2538. order_by=str("Token.created"),
  2539. ),
  2540. foreign_keys=[user_id],
  2541. remote_side=[User.id],
  2542. )
  2543. project = relation(
  2544. "Project",
  2545. backref=backref("tokens", cascade="delete, delete-orphan"),
  2546. foreign_keys=[project_id],
  2547. remote_side=[Project.id],
  2548. )
  2549. def __repr__(self):
  2550. """ Return a string representation of this object. """
  2551. return "Token: %s - name %s" % (self.id, self.expiration)
  2552. @property
  2553. def expired(self):
  2554. """ Returns whether a token has expired or not. """
  2555. if datetime.datetime.utcnow().date() >= self.expiration.date():
  2556. return True
  2557. else:
  2558. return False
  2559. @property
  2560. def acls_list(self):
  2561. """ Return a list containing the name of each ACLs this token has.
  2562. """
  2563. return sorted(["%s" % acl.name for acl in self.acls])
  2564. @property
  2565. def acls_list_pretty(self):
  2566. """
  2567. Return a list containing the description of each ACLs this token has.
  2568. """
  2569. return [
  2570. acl.description
  2571. for acl in sorted(self.acls, key=operator.attrgetter("name"))
  2572. ]
  2573. class TokenAcl(BASE):
  2574. """
  2575. Association table linking the tokens table to the acls table.
  2576. This allow linking token to acl.
  2577. """
  2578. __tablename__ = "tokens_acls"
  2579. token_id = sa.Column(
  2580. sa.String(64), sa.ForeignKey("tokens.id"), primary_key=True
  2581. )
  2582. acl_id = sa.Column(sa.Integer, sa.ForeignKey("acls.id"), primary_key=True)
  2583. # Constraints
  2584. __table_args__ = (sa.UniqueConstraint("token_id", "acl_id"),)
  2585. # ##########################################################
  2586. # These classes are only used if you're using the `local`
  2587. # authentication method
  2588. # ##########################################################
  2589. class PagureUserVisit(BASE):
  2590. """
  2591. Table storing the visits of the user.
  2592. """
  2593. __tablename__ = "pagure_user_visit"
  2594. id = sa.Column(sa.Integer, primary_key=True)
  2595. user_id = sa.Column(sa.Integer, sa.ForeignKey("users.id"), nullable=False)
  2596. visit_key = sa.Column(
  2597. sa.String(40), nullable=False, unique=True, index=True
  2598. )
  2599. user_ip = sa.Column(sa.String(50), nullable=False)
  2600. created = sa.Column(
  2601. sa.DateTime, nullable=False, default=datetime.datetime.utcnow
  2602. )
  2603. expiry = sa.Column(sa.DateTime)
  2604. class PagureUserGroup(BASE):
  2605. """
  2606. Association table linking the mm_user table to the mm_group table.
  2607. This allow linking users to groups.
  2608. """
  2609. __tablename__ = "pagure_user_group"
  2610. user_id = sa.Column(
  2611. sa.Integer, sa.ForeignKey("users.id"), primary_key=True
  2612. )
  2613. group_id = sa.Column(
  2614. sa.Integer, sa.ForeignKey("pagure_group.id"), primary_key=True
  2615. )
  2616. # Constraints
  2617. __table_args__ = (sa.UniqueConstraint("user_id", "group_id"),)
  2618. # Make sure to load the Plugin tables, so they have a chance to register
  2619. get_plugin_tables()