models.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. from datetime import timedelta
  2. from urllib.parse import urlparse
  3. from uuid import uuid4
  4. from django.conf import settings
  5. from django.db import models
  6. from django.db.models import Count, Q
  7. from django.utils.text import slugify
  8. from django_extensions.db.fields import AutoSlugField
  9. from glitchtip.base_models import CreatedModel
  10. from observability.metrics import clear_metrics_cache
  11. class Project(CreatedModel):
  12. """
  13. Projects are permission based namespaces which generally
  14. are the top level entry point for all data.
  15. """
  16. slug = AutoSlugField(populate_from=["name", "organization_id"], max_length=50)
  17. name = models.CharField(max_length=64)
  18. organization = models.ForeignKey(
  19. "organizations_ext.Organization",
  20. on_delete=models.CASCADE,
  21. related_name="projects",
  22. )
  23. platform = models.CharField(max_length=64, blank=True, null=True)
  24. first_event = models.DateTimeField(null=True)
  25. scrub_ip_addresses = models.BooleanField(
  26. default=True,
  27. help_text="Should project anonymize IP Addresses",
  28. )
  29. class Meta:
  30. unique_together = (("organization", "slug"),)
  31. def __str__(self):
  32. return self.name
  33. def save(self, *args, **kwargs):
  34. first = False
  35. if not self.pk:
  36. first = True
  37. super().save(*args, **kwargs)
  38. if first:
  39. clear_metrics_cache()
  40. ProjectKey.objects.create(project=self)
  41. def delete(self, *args, **kwargs):
  42. super().delete(*args, **kwargs)
  43. clear_metrics_cache()
  44. @property
  45. def should_scrub_ip_addresses(self):
  46. """Organization overrides project setting"""
  47. return self.scrub_ip_addresses or self.organization.scrub_ip_addresses
  48. def slugify_function(self, content):
  49. """
  50. Make the slug the project name. Validate uniqueness with both name and org id.
  51. This works because when it runs on organization_id it returns an empty string.
  52. """
  53. reserved_words = ["new"]
  54. slug = ""
  55. if isinstance(content, str):
  56. slug = slugify(self.name)
  57. if slug in reserved_words:
  58. slug += "-1"
  59. return slug
  60. class ProjectCounter(models.Model):
  61. """
  62. Counter for issue short IDs
  63. - Unique per project
  64. - Autoincrements on each new issue
  65. - Separate table for performance
  66. """
  67. project = models.OneToOneField(Project, on_delete=models.CASCADE)
  68. value = models.PositiveIntegerField()
  69. class ProjectKey(CreatedModel):
  70. """Authentication key for a Project"""
  71. project = models.ForeignKey(Project, on_delete=models.CASCADE)
  72. label = models.CharField(max_length=64, blank=True)
  73. public_key = models.UUIDField(default=uuid4, unique=True, editable=False)
  74. rate_limit_count = models.PositiveSmallIntegerField(blank=True, null=True)
  75. rate_limit_window = models.PositiveSmallIntegerField(blank=True, null=True)
  76. data = models.JSONField(blank=True, null=True)
  77. def __str__(self):
  78. return str(self.public_key)
  79. @classmethod
  80. def from_dsn(cls, dsn: str):
  81. urlparts = urlparse(dsn)
  82. public_key = urlparts.username
  83. project_id = urlparts.path.rsplit("/", 1)[-1]
  84. try:
  85. return ProjectKey.objects.get(public_key=public_key, project=project_id)
  86. except ValueError as err:
  87. # ValueError would come from a non-integer project_id,
  88. # which is obviously a DoesNotExist. We catch and rethrow this
  89. # so anything downstream expecting DoesNotExist works fine
  90. raise ProjectKey.DoesNotExist(
  91. "ProjectKey matching query does not exist."
  92. ) from err
  93. @property
  94. def public_key_hex(self):
  95. """The public key without dashes"""
  96. return self.public_key.hex
  97. def dsn(self):
  98. return self.get_dsn()
  99. def get_dsn(self):
  100. urlparts = settings.GLITCHTIP_URL
  101. # If we do not have a scheme or domain/hostname, dsn is never valid
  102. if not urlparts.netloc or not urlparts.scheme:
  103. return ""
  104. return "%s://%s@%s/%s" % (
  105. urlparts.scheme,
  106. self.public_key_hex,
  107. urlparts.netloc + urlparts.path,
  108. self.project_id,
  109. )
  110. def get_dsn_security(self):
  111. urlparts = settings.GLITCHTIP_URL
  112. if not urlparts.netloc or not urlparts.scheme:
  113. return ""
  114. return "%s://%s/api/%s/security/?glitchtip_key=%s" % (
  115. urlparts.scheme,
  116. urlparts.netloc + urlparts.path,
  117. self.project_id,
  118. self.public_key_hex,
  119. )
  120. class ProjectStatisticBase(models.Model):
  121. project = models.ForeignKey("projects.Project", on_delete=models.CASCADE)
  122. date = models.DateTimeField()
  123. count = models.PositiveIntegerField()
  124. class Meta:
  125. unique_together = (("project", "date"),)
  126. abstract = True
  127. @classmethod
  128. def update(cls, project_id: int, start_time: "datetime"):
  129. """
  130. Update current hour and last hour statistics
  131. start_time should be the time of the last known event creation
  132. This method recalculates both stats, replacing any previous entry
  133. """
  134. current_hour = start_time.replace(second=0, microsecond=0, minute=0)
  135. next_hour = current_hour + timedelta(hours=1)
  136. previous_hour = current_hour - timedelta(hours=1)
  137. projects = Project.objects.filter(pk=project_id)
  138. event_counts = cls.aggregate_queryset(
  139. projects, previous_hour, current_hour, next_hour
  140. )
  141. statistics = []
  142. if event_counts["previous_hour_count"]:
  143. statistics.append(
  144. cls(
  145. project_id=project_id,
  146. date=previous_hour,
  147. count=event_counts["previous_hour_count"],
  148. )
  149. )
  150. if event_counts["current_hour_count"]:
  151. statistics.append(
  152. cls(
  153. project_id=project_id,
  154. date=current_hour,
  155. count=event_counts["current_hour_count"],
  156. )
  157. )
  158. if statistics:
  159. cls.objects.bulk_create(
  160. statistics,
  161. update_conflicts=True,
  162. unique_fields=["project", "date"],
  163. update_fields=["count"],
  164. )
  165. class TransactionEventProjectHourlyStatistic(ProjectStatisticBase):
  166. @classmethod
  167. def aggregate_queryset(
  168. cls,
  169. project_queryset,
  170. previous_hour: "datetime",
  171. current_hour: "datetime",
  172. next_hour: "datetime",
  173. ):
  174. # Redundant filter optimization - otherwise all rows are scanned
  175. return project_queryset.filter(
  176. transactiongroup__transactionevent__created__gte=previous_hour,
  177. transactiongroup__transactionevent__created__lt=next_hour,
  178. ).aggregate(
  179. previous_hour_count=Count(
  180. "transactiongroup__transactionevent",
  181. filter=Q(
  182. transactiongroup__transactionevent__created__gte=previous_hour,
  183. transactiongroup__transactionevent__created__lt=current_hour,
  184. ),
  185. ),
  186. current_hour_count=Count(
  187. "transactiongroup__transactionevent",
  188. filter=Q(
  189. transactiongroup__transactionevent__created__gte=current_hour,
  190. transactiongroup__transactionevent__created__lt=next_hour,
  191. ),
  192. ),
  193. )
  194. class EventProjectHourlyStatistic(ProjectStatisticBase):
  195. @classmethod
  196. def aggregate_queryset(
  197. cls,
  198. project_queryset,
  199. previous_hour: "datetime",
  200. current_hour: "datetime",
  201. next_hour: "datetime",
  202. ):
  203. # Redundant filter optimization - otherwise all rows are scanned
  204. return project_queryset.filter(
  205. issue__event__created__gte=previous_hour,
  206. issue__event__created__lt=next_hour,
  207. ).aggregate(
  208. previous_hour_count=Count(
  209. "issue__event",
  210. filter=Q(
  211. issue__event__created__gte=previous_hour,
  212. issue__event__created__lt=current_hour,
  213. ),
  214. ),
  215. current_hour_count=Count(
  216. "issue__event",
  217. filter=Q(
  218. issue__event__created__gte=current_hour,
  219. issue__event__created__lt=next_hour,
  220. ),
  221. ),
  222. )
  223. class ProjectAlertStatus(models.IntegerChoices):
  224. OFF = 0, "off"
  225. ON = 1, "on"
  226. class UserProjectAlert(models.Model):
  227. """
  228. Determine if user alert notifications should always happen, never, or defer to default
  229. Default is stored as the lack of record.
  230. """
  231. user = models.ForeignKey("users.User", on_delete=models.CASCADE)
  232. project = models.ForeignKey("projects.Project", on_delete=models.CASCADE)
  233. status = models.PositiveSmallIntegerField(choices=ProjectAlertStatus.choices)
  234. class Meta:
  235. unique_together = ("user", "project")