project.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. from __future__ import absolute_import, print_function
  2. import logging
  3. import warnings
  4. from collections import defaultdict
  5. import six
  6. from bitfield import BitField
  7. from django.conf import settings
  8. from django.db import IntegrityError, models, transaction
  9. from django.db.models.signals import pre_delete
  10. from django.utils import timezone
  11. from django.utils.translation import ugettext_lazy as _
  12. from django.utils.http import urlencode
  13. from uuid import uuid1
  14. from sentry import projectoptions
  15. from sentry.app import locks
  16. from sentry.constants import ObjectStatus, RESERVED_PROJECT_SLUGS
  17. from sentry.db.mixin import PendingDeletionMixin, delete_pending_deletion_option
  18. from sentry.db.models import (
  19. BaseManager,
  20. BoundedPositiveIntegerField,
  21. FlexibleForeignKey,
  22. Model,
  23. sane_repr,
  24. )
  25. from sentry.db.models.utils import slugify_instance
  26. from sentry.utils.integrationdocs import integration_doc_exists
  27. from sentry.utils.colors import get_hashed_color
  28. from sentry.utils.http import absolute_uri
  29. from sentry.utils.retries import TimedRetryPolicy
  30. # TODO(dcramer): pull in enum library
  31. ProjectStatus = ObjectStatus
  32. class ProjectTeam(Model):
  33. __core__ = True
  34. project = FlexibleForeignKey("sentry.Project")
  35. team = FlexibleForeignKey("sentry.Team")
  36. class Meta:
  37. app_label = "sentry"
  38. db_table = "sentry_projectteam"
  39. unique_together = (("project", "team"),)
  40. class ProjectManager(BaseManager):
  41. # TODO(dcramer): we might want to cache this per user
  42. def get_for_user(self, team, user, scope=None, _skip_team_check=False):
  43. from sentry.models import Team
  44. if not (user and user.is_authenticated()):
  45. return []
  46. if not _skip_team_check:
  47. team_list = Team.objects.get_for_user(
  48. organization=team.organization, user=user, scope=scope
  49. )
  50. try:
  51. team = team_list[team_list.index(team)]
  52. except ValueError:
  53. logging.info("User does not have access to team: %s", team.id)
  54. return []
  55. base_qs = self.filter(teams=team, status=ProjectStatus.VISIBLE)
  56. project_list = []
  57. for project in base_qs:
  58. project_list.append(project)
  59. return sorted(project_list, key=lambda x: x.name.lower())
  60. class Project(Model, PendingDeletionMixin):
  61. """
  62. Projects are permission based namespaces which generally
  63. are the top level entry point for all data.
  64. """
  65. __core__ = True
  66. slug = models.SlugField(null=True)
  67. name = models.CharField(max_length=200)
  68. forced_color = models.CharField(max_length=6, null=True, blank=True)
  69. organization = FlexibleForeignKey("sentry.Organization")
  70. teams = models.ManyToManyField("sentry.Team", related_name="teams", through=ProjectTeam)
  71. public = models.BooleanField(default=False)
  72. date_added = models.DateTimeField(default=timezone.now)
  73. status = BoundedPositiveIntegerField(
  74. default=0,
  75. choices=(
  76. (ObjectStatus.VISIBLE, _("Active")),
  77. (ObjectStatus.PENDING_DELETION, _("Pending Deletion")),
  78. (ObjectStatus.DELETION_IN_PROGRESS, _("Deletion in Progress")),
  79. ),
  80. db_index=True,
  81. )
  82. # projects that were created before this field was present
  83. # will have their first_event field set to date_added
  84. first_event = models.DateTimeField(null=True)
  85. flags = BitField(
  86. flags=(
  87. ("has_releases", "This Project has sent release data"),
  88. ("has_issue_alerts_targeting", "This Project has issue alerts targeting"),
  89. ("has_transactions", "This Project has sent transactions"),
  90. ),
  91. default=0,
  92. null=True,
  93. )
  94. objects = ProjectManager(cache_fields=["pk"])
  95. platform = models.CharField(max_length=64, null=True)
  96. class Meta:
  97. app_label = "sentry"
  98. db_table = "sentry_project"
  99. unique_together = (("organization", "slug"),)
  100. __repr__ = sane_repr("team_id", "name", "slug")
  101. _rename_fields_on_pending_delete = frozenset(["slug"])
  102. def __unicode__(self):
  103. return u"%s (%s)" % (self.name, self.slug)
  104. def next_short_id(self):
  105. from sentry.models import Counter
  106. return Counter.increment(self)
  107. def save(self, *args, **kwargs):
  108. if not self.slug:
  109. lock = locks.get("slug:project", duration=5)
  110. with TimedRetryPolicy(10)(lock.acquire):
  111. slugify_instance(
  112. self, self.name, organization=self.organization, reserved=RESERVED_PROJECT_SLUGS
  113. )
  114. super(Project, self).save(*args, **kwargs)
  115. else:
  116. super(Project, self).save(*args, **kwargs)
  117. self.update_rev_for_option()
  118. def get_absolute_url(self, params=None):
  119. url = u"/organizations/{}/issues/".format(self.organization.slug)
  120. params = {} if params is None else params
  121. params["project"] = self.id
  122. if params:
  123. url = url + "?" + urlencode(params)
  124. return absolute_uri(url)
  125. def is_internal_project(self):
  126. for value in (settings.SENTRY_FRONTEND_PROJECT, settings.SENTRY_PROJECT):
  127. if six.text_type(self.id) == six.text_type(value) or six.text_type(
  128. self.slug
  129. ) == six.text_type(value):
  130. return True
  131. return False
  132. # TODO: Make these a mixin
  133. def update_option(self, *args, **kwargs):
  134. return projectoptions.set(self, *args, **kwargs)
  135. def get_option(self, *args, **kwargs):
  136. return projectoptions.get(self, *args, **kwargs)
  137. def delete_option(self, *args, **kwargs):
  138. return projectoptions.delete(self, *args, **kwargs)
  139. def update_rev_for_option(self):
  140. return projectoptions.update_rev_for_option(self)
  141. @property
  142. def callsign(self):
  143. warnings.warn(
  144. "Project.callsign is deprecated. Use Group.get_short_id() instead.", DeprecationWarning
  145. )
  146. return self.slug.upper()
  147. @property
  148. def color(self):
  149. if self.forced_color is not None:
  150. return "#%s" % self.forced_color
  151. return get_hashed_color(self.callsign or self.slug)
  152. @property
  153. def member_set(self):
  154. from sentry.models import OrganizationMember
  155. return self.organization.member_set.filter(
  156. id__in=OrganizationMember.objects.filter(
  157. organizationmemberteam__is_active=True,
  158. organizationmemberteam__team__in=self.teams.all(),
  159. ).values("id"),
  160. user__is_active=True,
  161. ).distinct()
  162. def has_access(self, user, access=None):
  163. from sentry.models import AuthIdentity, OrganizationMember
  164. warnings.warn("Project.has_access is deprecated.", DeprecationWarning)
  165. queryset = self.member_set.filter(user=user)
  166. if access is not None:
  167. queryset = queryset.filter(type__lte=access)
  168. try:
  169. member = queryset.get()
  170. except OrganizationMember.DoesNotExist:
  171. return False
  172. try:
  173. auth_identity = AuthIdentity.objects.get(
  174. auth_provider__organization=self.organization_id, user=member.user_id
  175. )
  176. except AuthIdentity.DoesNotExist:
  177. return True
  178. return auth_identity.is_valid(member)
  179. def get_audit_log_data(self):
  180. return {
  181. "id": self.id,
  182. "slug": self.slug,
  183. "name": self.name,
  184. "status": self.status,
  185. "public": self.public,
  186. }
  187. def get_full_name(self):
  188. return self.slug
  189. def get_member_alert_settings(self, user_option):
  190. """
  191. Returns a list of users who have alert notifications explicitly
  192. enabled/disabled.
  193. :param user_option: alert option key, typically 'mail:alert'
  194. :return: A dictionary in format {<user_id>: <int_alert_value>}
  195. """
  196. from sentry.models import UserOption
  197. return {
  198. o.user_id: int(o.value)
  199. for o in UserOption.objects.filter(project=self, key=user_option)
  200. }
  201. def get_notification_recipients(self, user_option):
  202. from sentry.models import UserOption
  203. alert_settings = self.get_member_alert_settings(user_option)
  204. disabled = set(u for u, v in six.iteritems(alert_settings) if v == 0)
  205. member_set = set(self.member_set.exclude(user__in=disabled).values_list("user", flat=True))
  206. # determine members default settings
  207. members_to_check = set(u for u in member_set if u not in alert_settings)
  208. if members_to_check:
  209. disabled = set(
  210. (
  211. uo.user_id
  212. for uo in UserOption.objects.filter(
  213. key="subscribe_by_default", user__in=members_to_check
  214. )
  215. if uo.value == "0"
  216. )
  217. )
  218. member_set = [x for x in member_set if x not in disabled]
  219. return member_set
  220. def get_mail_alert_subscribers(self):
  221. user_ids = self.get_notification_recipients("mail:alert")
  222. if not user_ids:
  223. return []
  224. from sentry.models import User
  225. return list(User.objects.filter(id__in=user_ids))
  226. def is_user_subscribed_to_mail_alerts(self, user):
  227. from sentry.models import UserOption
  228. is_enabled = UserOption.objects.get_value(user, "mail:alert", project=self)
  229. if is_enabled is None:
  230. is_enabled = UserOption.objects.get_value(user, "subscribe_by_default", "1") == "1"
  231. else:
  232. is_enabled = bool(is_enabled)
  233. return is_enabled
  234. def filter_to_subscribed_users(self, users):
  235. """
  236. Filters a list of users down to the users who are subscribed to email alerts. We
  237. check both the project level settings and global default settings.
  238. """
  239. from sentry.models import UserOption
  240. project_options = UserOption.objects.filter(
  241. user__in=users, project=self, key="mail:alert"
  242. ).values_list("user_id", "value")
  243. user_settings = {user_id: value for user_id, value in project_options}
  244. users_without_project_setting = [user for user in users if user.id not in user_settings]
  245. if users_without_project_setting:
  246. user_default_settings = {
  247. user_id: value
  248. for user_id, value in UserOption.objects.filter(
  249. user__in=users_without_project_setting,
  250. key="subscribe_by_default",
  251. project__isnull=True,
  252. ).values_list("user_id", "value")
  253. }
  254. for user in users_without_project_setting:
  255. user_settings[user.id] = int(user_default_settings.get(user.id, "1"))
  256. return [user for user in users if bool(user_settings[user.id])]
  257. def transfer_to(self, team=None, organization=None):
  258. # NOTE: this will only work properly if the new team is in a different
  259. # org than the existing one, which is currently the only use case in
  260. # production
  261. # TODO(jess): refactor this to make it an org transfer only
  262. from sentry.models import (
  263. Environment,
  264. EnvironmentProject,
  265. ProjectTeam,
  266. ReleaseProject,
  267. ReleaseProjectEnvironment,
  268. Rule,
  269. )
  270. if organization is None:
  271. organization = team.organization
  272. old_org_id = self.organization_id
  273. org_changed = old_org_id != organization.id
  274. self.organization = organization
  275. try:
  276. with transaction.atomic():
  277. self.update(organization=organization)
  278. except IntegrityError:
  279. slugify_instance(self, self.name, organization=organization)
  280. self.update(slug=self.slug, organization=organization)
  281. # Both environments and releases are bound at an organization level.
  282. # Due to this, when you transfer a project into another org, we have to
  283. # handle this behavior somehow. We really only have two options here:
  284. # * Copy over all releases/environments into the new org and handle de-duping
  285. # * Delete the bindings and let them reform with new data.
  286. # We're generally choosing to just delete the bindings since new data
  287. # flowing in will recreate links correctly. The tradeoff is that
  288. # historical data is lost, but this is a compromise we're willing to
  289. # take and a side effect of allowing this feature. There are exceptions
  290. # to this however, such as rules, which should maintain their
  291. # configuration when moved across organizations.
  292. if org_changed:
  293. for model in ReleaseProject, ReleaseProjectEnvironment, EnvironmentProject:
  294. model.objects.filter(project_id=self.id).delete()
  295. # this is getting really gross, but make sure there aren't lingering associations
  296. # with old orgs or teams
  297. ProjectTeam.objects.filter(project=self, team__organization_id=old_org_id).delete()
  298. rules_by_environment_id = defaultdict(set)
  299. for rule_id, environment_id in Rule.objects.filter(
  300. project_id=self.id, environment_id__isnull=False
  301. ).values_list("id", "environment_id"):
  302. rules_by_environment_id[environment_id].add(rule_id)
  303. environment_names = dict(
  304. Environment.objects.filter(id__in=rules_by_environment_id).values_list("id", "name")
  305. )
  306. for environment_id, rule_ids in rules_by_environment_id.items():
  307. Rule.objects.filter(id__in=rule_ids).update(
  308. environment_id=Environment.get_or_create(self, environment_names[environment_id]).id
  309. )
  310. # ensure this actually exists in case from team was null
  311. if team is not None:
  312. self.add_team(team)
  313. def add_team(self, team):
  314. try:
  315. with transaction.atomic():
  316. ProjectTeam.objects.create(project=self, team=team)
  317. except IntegrityError:
  318. return False
  319. else:
  320. return True
  321. def remove_team(self, team):
  322. ProjectTeam.objects.filter(project=self, team=team).delete()
  323. def get_security_token(self):
  324. lock = locks.get(self.get_lock_key(), duration=5)
  325. with TimedRetryPolicy(10)(lock.acquire):
  326. security_token = self.get_option("sentry:token", None)
  327. if security_token is None:
  328. security_token = uuid1().hex
  329. self.update_option("sentry:token", security_token)
  330. return security_token
  331. def get_lock_key(self):
  332. return "project_token:%s" % self.id
  333. def copy_settings_from(self, project_id):
  334. """
  335. Copies project level settings of the inputted project
  336. - General Settings
  337. - ProjectTeams
  338. - Alerts Settings and Rules
  339. - EnvironmentProjects
  340. - ProjectOwnership Rules and settings
  341. - Project Inbound Data Filters
  342. Returns True if the settings have successfully been copied over
  343. Returns False otherwise
  344. """
  345. from sentry.models import EnvironmentProject, ProjectOption, ProjectOwnership, Rule
  346. model_list = [EnvironmentProject, ProjectOwnership, ProjectTeam, Rule]
  347. project = Project.objects.get(id=project_id)
  348. try:
  349. with transaction.atomic():
  350. for model in model_list:
  351. # remove all previous project settings
  352. model.objects.filter(project_id=self.id).delete()
  353. # add settings from other project to self
  354. for setting in model.objects.filter(project_id=project_id):
  355. setting.pk = None
  356. setting.project_id = self.id
  357. setting.save()
  358. options = ProjectOption.objects.get_all_values(project=project)
  359. for key, value in six.iteritems(options):
  360. self.update_option(key, value)
  361. except IntegrityError as e:
  362. logging.exception(
  363. "Error occurred during copy project settings.",
  364. extra={
  365. "error": six.text_type(e),
  366. "project_to": self.id,
  367. "project_from": project_id,
  368. },
  369. )
  370. return False
  371. return True
  372. @staticmethod
  373. def is_valid_platform(value):
  374. if not value or value == "other":
  375. return True
  376. return integration_doc_exists(value)
  377. pre_delete.connect(delete_pending_deletion_option, sender=Project, weak=False)