models.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. import random
  2. from datetime import timedelta
  3. from urllib.parse import urlparse
  4. from uuid import uuid4
  5. from django.conf import settings
  6. from django.core.validators import MaxValueValidator, MinValueValidator
  7. from django.db import models
  8. from django.db.models import Count, Q
  9. from django.utils.text import slugify
  10. from django_extensions.db.fields import AutoSlugField
  11. from glitchtip.base_models import CreatedModel, SoftDeleteModel
  12. from observability.metrics import clear_metrics_cache
  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. def save(self, *args, **kwargs):
  41. first = False
  42. if not self.pk:
  43. first = True
  44. super().save(*args, **kwargs)
  45. if first:
  46. clear_metrics_cache()
  47. ProjectKey.objects.create(project=self)
  48. def delete(self, *args, **kwargs):
  49. """Mark the record as deleted instead of deleting it"""
  50. # avoid circular import
  51. from projects.tasks import delete_project
  52. super().delete(*args, **kwargs)
  53. delete_project.delay(self.pk)
  54. def force_delete(self, *args, **kwargs):
  55. """Really delete the project and all related data."""
  56. # avoid circular import
  57. from events.models import Event
  58. from issues.models import Issue
  59. # bulk delete all events
  60. events_qs = Event.objects.filter(issue__project=self)
  61. events_qs._raw_delete(events_qs.db)
  62. # bulk delete all issues in batches of 1k
  63. issues_qs = self.issue_set.order_by("id")
  64. while True:
  65. try:
  66. issue_delimiter = issues_qs.values_list("id", flat=True)[
  67. 1000:1001
  68. ].get()
  69. issues_qs.filter(id__lte=issue_delimiter).delete()
  70. except Issue.DoesNotExist:
  71. break
  72. issues_qs.delete()
  73. # lastly delete the project itself
  74. super().force_delete(*args, **kwargs)
  75. clear_metrics_cache()
  76. @property
  77. def should_scrub_ip_addresses(self):
  78. """Organization overrides project setting"""
  79. return self.scrub_ip_addresses or self.organization.scrub_ip_addresses
  80. def slugify_function(self, content):
  81. """
  82. Make the slug the project name. Validate uniqueness with both name and org id.
  83. This works because when it runs on organization_id it returns an empty string.
  84. """
  85. reserved_words = ["new"]
  86. slug = ""
  87. if isinstance(content, str):
  88. slug = slugify(self.name)
  89. if slug in reserved_words:
  90. slug += "-1"
  91. return slug
  92. @property
  93. def is_accepting_events(self):
  94. """Is the project in its limits for event creation"""
  95. if self.event_throttle_rate == 0:
  96. return True
  97. return random.randint(0, 100) > self.event_throttle_rate
  98. class ProjectCounter(models.Model):
  99. """
  100. Counter for issue short IDs
  101. - Unique per project
  102. - Autoincrements on each new issue
  103. - Separate table for performance
  104. """
  105. project = models.OneToOneField(Project, on_delete=models.CASCADE)
  106. value = models.PositiveIntegerField()
  107. class ProjectKey(CreatedModel):
  108. """Authentication key for a Project"""
  109. project = models.ForeignKey(Project, on_delete=models.CASCADE)
  110. label = models.CharField(max_length=64, blank=True)
  111. public_key = models.UUIDField(default=uuid4, unique=True, editable=False)
  112. rate_limit_count = models.PositiveSmallIntegerField(blank=True, null=True)
  113. rate_limit_window = models.PositiveSmallIntegerField(blank=True, null=True)
  114. data = models.JSONField(blank=True, null=True)
  115. def __str__(self):
  116. return str(self.public_key)
  117. @classmethod
  118. def from_dsn(cls, dsn: str):
  119. urlparts = urlparse(dsn)
  120. public_key = urlparts.username
  121. project_id = urlparts.path.rsplit("/", 1)[-1]
  122. try:
  123. return ProjectKey.objects.get(public_key=public_key, project=project_id)
  124. except ValueError as err:
  125. # ValueError would come from a non-integer project_id,
  126. # which is obviously a DoesNotExist. We catch and rethrow this
  127. # so anything downstream expecting DoesNotExist works fine
  128. raise ProjectKey.DoesNotExist(
  129. "ProjectKey matching query does not exist."
  130. ) from err
  131. @property
  132. def public_key_hex(self):
  133. """The public key without dashes"""
  134. return self.public_key.hex
  135. def dsn(self):
  136. return self.get_dsn()
  137. def get_dsn(self):
  138. urlparts = settings.GLITCHTIP_URL
  139. # If we do not have a scheme or domain/hostname, dsn is never valid
  140. if not urlparts.netloc or not urlparts.scheme:
  141. return ""
  142. return "%s://%s@%s/%s" % (
  143. urlparts.scheme,
  144. self.public_key_hex,
  145. urlparts.netloc + urlparts.path,
  146. self.project_id,
  147. )
  148. def get_dsn_security(self):
  149. urlparts = settings.GLITCHTIP_URL
  150. if not urlparts.netloc or not urlparts.scheme:
  151. return ""
  152. return "%s://%s/api/%s/security/?glitchtip_key=%s" % (
  153. urlparts.scheme,
  154. urlparts.netloc + urlparts.path,
  155. self.project_id,
  156. self.public_key_hex,
  157. )
  158. class ProjectStatisticBase(models.Model):
  159. project = models.ForeignKey("projects.Project", on_delete=models.CASCADE)
  160. date = models.DateTimeField()
  161. count = models.PositiveIntegerField()
  162. class Meta:
  163. unique_together = (("project", "date"),)
  164. abstract = True
  165. @classmethod
  166. def update(cls, project_id: int, start_time: "datetime"):
  167. """
  168. Update current hour and last hour statistics
  169. start_time should be the time of the last known event creation
  170. This method recalculates both stats, replacing any previous entry
  171. """
  172. current_hour = start_time.replace(second=0, microsecond=0, minute=0)
  173. next_hour = current_hour + timedelta(hours=1)
  174. previous_hour = current_hour - timedelta(hours=1)
  175. projects = Project.objects.filter(pk=project_id)
  176. event_counts = cls.aggregate_queryset(
  177. projects, previous_hour, current_hour, next_hour
  178. )
  179. statistics = []
  180. if event_counts["previous_hour_count"]:
  181. statistics.append(
  182. cls(
  183. project_id=project_id,
  184. date=previous_hour,
  185. count=event_counts["previous_hour_count"],
  186. )
  187. )
  188. if event_counts["current_hour_count"]:
  189. statistics.append(
  190. cls(
  191. project_id=project_id,
  192. date=current_hour,
  193. count=event_counts["current_hour_count"],
  194. )
  195. )
  196. if statistics:
  197. cls.objects.bulk_create(
  198. statistics,
  199. update_conflicts=True,
  200. unique_fields=["project", "date"],
  201. update_fields=["count"],
  202. )
  203. class TransactionEventProjectHourlyStatistic(ProjectStatisticBase):
  204. @classmethod
  205. def aggregate_queryset(
  206. cls,
  207. project_queryset,
  208. previous_hour: "datetime",
  209. current_hour: "datetime",
  210. next_hour: "datetime",
  211. ):
  212. # Redundant filter optimization - otherwise all rows are scanned
  213. return project_queryset.filter(
  214. transactiongroup__transactionevent__created__gte=previous_hour,
  215. transactiongroup__transactionevent__created__lt=next_hour,
  216. ).aggregate(
  217. previous_hour_count=Count(
  218. "transactiongroup__transactionevent",
  219. filter=Q(
  220. transactiongroup__transactionevent__created__gte=previous_hour,
  221. transactiongroup__transactionevent__created__lt=current_hour,
  222. ),
  223. ),
  224. current_hour_count=Count(
  225. "transactiongroup__transactionevent",
  226. filter=Q(
  227. transactiongroup__transactionevent__created__gte=current_hour,
  228. transactiongroup__transactionevent__created__lt=next_hour,
  229. ),
  230. ),
  231. )
  232. class EventProjectHourlyStatistic(ProjectStatisticBase):
  233. @classmethod
  234. def aggregate_queryset(
  235. cls,
  236. project_queryset,
  237. previous_hour: "datetime",
  238. current_hour: "datetime",
  239. next_hour: "datetime",
  240. ):
  241. # Redundant filter optimization - otherwise all rows are scanned
  242. return project_queryset.filter(
  243. issue__event__created__gte=previous_hour,
  244. issue__event__created__lt=next_hour,
  245. ).aggregate(
  246. previous_hour_count=Count(
  247. "issue__event",
  248. filter=Q(
  249. issue__event__created__gte=previous_hour,
  250. issue__event__created__lt=current_hour,
  251. ),
  252. ),
  253. current_hour_count=Count(
  254. "issue__event",
  255. filter=Q(
  256. issue__event__created__gte=current_hour,
  257. issue__event__created__lt=next_hour,
  258. ),
  259. ),
  260. )
  261. class ProjectAlertStatus(models.IntegerChoices):
  262. OFF = 0, "off"
  263. ON = 1, "on"
  264. class UserProjectAlert(models.Model):
  265. """
  266. Determine if user alert notifications should always happen, never, or defer to default
  267. Default is stored as the lack of record.
  268. """
  269. user = models.ForeignKey("users.User", on_delete=models.CASCADE)
  270. project = models.ForeignKey("projects.Project", on_delete=models.CASCADE)
  271. status = models.PositiveSmallIntegerField(choices=ProjectAlertStatus.choices)
  272. class Meta:
  273. unique_together = ("user", "project")