models.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. from urllib.parse import urlparse
  2. from uuid import uuid4
  3. from django.conf import settings
  4. from django.core.validators import MaxValueValidator
  5. from django.db import models
  6. from django.db.models import Count, Q, QuerySet
  7. from django.db.models.functions import Cast
  8. from django.utils.text import slugify
  9. from django_extensions.db.fields import AutoSlugField
  10. from apps.issue_events.models import Issue, IssueEvent
  11. from apps.observability.metrics import clear_metrics_cache
  12. from glitchtip.base_models import AggregationModel, CreatedModel, SoftDeleteModel
  13. class Project(CreatedModel, SoftDeleteModel):
  14. """
  15. Projects are permission based namespaces which generally
  16. are the top level entry point for all data.
  17. """
  18. slug = AutoSlugField(populate_from=["name", "organization_id"], max_length=50)
  19. name = models.CharField(max_length=64)
  20. organization = models.ForeignKey(
  21. "organizations_ext.Organization",
  22. on_delete=models.CASCADE,
  23. related_name="projects",
  24. )
  25. platform = models.CharField(max_length=64, blank=True, null=True)
  26. first_event = models.DateTimeField(null=True)
  27. scrub_ip_addresses = models.BooleanField(
  28. default=True,
  29. help_text="Should project anonymize IP Addresses",
  30. )
  31. event_throttle_rate = models.PositiveSmallIntegerField(
  32. default=0,
  33. validators=[MaxValueValidator(100)],
  34. help_text="Probability (in percent) on how many events are throttled. Used for throttling at project level",
  35. )
  36. class Meta:
  37. unique_together = (("organization", "slug"),)
  38. def __str__(self):
  39. return self.name
  40. @classmethod
  41. def annotate_is_member(cls, queryset: QuerySet, user_id: int):
  42. """Add is_member boolean annotate to Project queryset"""
  43. return queryset.annotate(
  44. is_member=Cast(
  45. Cast( # Postgres can cast int to bool, but not bigint to bool
  46. Count(
  47. "teams__members",
  48. filter=Q(teams__members__user_id=user_id),
  49. distinct=True,
  50. ),
  51. output_field=models.IntegerField(),
  52. ),
  53. output_field=models.BooleanField(),
  54. ),
  55. )
  56. def save(self, *args, **kwargs):
  57. first = False
  58. if not self.pk:
  59. first = True
  60. super().save(*args, **kwargs)
  61. if first:
  62. clear_metrics_cache()
  63. ProjectKey.objects.create(project=self)
  64. def delete(self, *args, **kwargs):
  65. """Mark the record as deleted instead of deleting it"""
  66. # avoid circular import
  67. from apps.projects.tasks import delete_project
  68. super().delete(*args, **kwargs)
  69. delete_project.delay(self.pk)
  70. def force_delete(self, *args, **kwargs):
  71. """Really delete the project and all related data."""
  72. # bulk delete all events
  73. events_qs = IssueEvent.objects.filter(issue__project=self)
  74. events_qs._raw_delete(events_qs.db)
  75. # bulk delete all issues in batches of 1k
  76. issues_qs = self.issues.order_by("id")
  77. while True:
  78. try:
  79. issue_delimiter = issues_qs.values_list("id", flat=True)[
  80. 1000:1001
  81. ].get()
  82. issues_qs.filter(id__lte=issue_delimiter).delete()
  83. except Issue.DoesNotExist:
  84. break
  85. issues_qs.delete()
  86. # lastly delete the project itself
  87. super().force_delete(*args, **kwargs)
  88. clear_metrics_cache()
  89. def slugify_function(self, content):
  90. """
  91. Make the slug the project name. Validate uniqueness with both name and org id.
  92. This works because when it runs on organization_id it returns an empty string.
  93. """
  94. reserved_words = ["new"]
  95. slug = ""
  96. if isinstance(content, str):
  97. slug = slugify(self.name)
  98. if slug in reserved_words:
  99. slug += "-1"
  100. return slug
  101. class ProjectCounter(models.Model):
  102. """
  103. Counter for issue short IDs
  104. - Unique per project
  105. - Autoincrements on each new issue
  106. - Separate table for performance
  107. """
  108. project = models.OneToOneField(Project, on_delete=models.CASCADE)
  109. value = models.PositiveIntegerField()
  110. class ProjectKey(CreatedModel):
  111. """Authentication key for a Project"""
  112. project = models.ForeignKey(Project, on_delete=models.CASCADE)
  113. is_active = models.BooleanField(default=True)
  114. name = models.CharField(max_length=64, blank=True)
  115. public_key = models.UUIDField(default=uuid4, unique=True, editable=False)
  116. rate_limit_count = models.PositiveSmallIntegerField(blank=True, null=True)
  117. rate_limit_window = models.PositiveSmallIntegerField(blank=True, null=True)
  118. data = models.JSONField(blank=True, null=True)
  119. def __str__(self):
  120. return str(self.public_key)
  121. @classmethod
  122. def from_dsn(cls, dsn: str):
  123. urlparts = urlparse(dsn)
  124. public_key = urlparts.username
  125. project_id = urlparts.path.rsplit("/", 1)[-1]
  126. try:
  127. return ProjectKey.objects.get(public_key=public_key, project=project_id)
  128. except ValueError as err:
  129. # ValueError would come from a non-integer project_id,
  130. # which is obviously a DoesNotExist. We catch and rethrow this
  131. # so anything downstream expecting DoesNotExist works fine
  132. raise ProjectKey.DoesNotExist(
  133. "ProjectKey matching query does not exist."
  134. ) from err
  135. @property
  136. def public_key_hex(self):
  137. """The public key without dashes"""
  138. return self.public_key.hex
  139. def dsn(self):
  140. return self.get_dsn()
  141. def get_dsn(self):
  142. urlparts = settings.GLITCHTIP_URL
  143. # If we do not have a scheme or domain/hostname, dsn is never valid
  144. if not urlparts.netloc or not urlparts.scheme:
  145. return ""
  146. return "%s://%s@%s/%s" % (
  147. urlparts.scheme,
  148. self.public_key_hex,
  149. urlparts.netloc + urlparts.path,
  150. self.project_id,
  151. )
  152. def get_dsn_security(self):
  153. urlparts = settings.GLITCHTIP_URL
  154. if not urlparts.netloc or not urlparts.scheme:
  155. return ""
  156. return "%s://%s/api/%s/security/?glitchtip_key=%s" % (
  157. urlparts.scheme,
  158. urlparts.netloc + urlparts.path,
  159. self.project_id,
  160. self.public_key_hex,
  161. )
  162. class ProjectStatisticBase(AggregationModel):
  163. project = models.ForeignKey("projects.Project", on_delete=models.CASCADE)
  164. class Meta:
  165. unique_together = (("project", "date"),)
  166. abstract = True
  167. class TransactionEventProjectHourlyStatistic(ProjectStatisticBase):
  168. class PartitioningMeta(AggregationModel.PartitioningMeta):
  169. pass
  170. class IssueEventProjectHourlyStatistic(ProjectStatisticBase):
  171. class PartitioningMeta(AggregationModel.PartitioningMeta):
  172. pass
  173. class ProjectAlertStatus(models.IntegerChoices):
  174. OFF = 0, "off"
  175. ON = 1, "on"
  176. class UserProjectAlert(models.Model):
  177. """
  178. Determine if user alert notifications should always happen, never, or defer to default
  179. Default is stored as the lack of record.
  180. """
  181. user = models.ForeignKey("users.User", on_delete=models.CASCADE)
  182. project = models.ForeignKey("projects.Project", on_delete=models.CASCADE)
  183. status = models.PositiveSmallIntegerField(choices=ProjectAlertStatus.choices)
  184. class Meta:
  185. unique_together = ("user", "project")