You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

456 lines
16 KiB

10 years ago
8 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
  1. from datetime import timedelta
  2. import random
  3. from secrets import token_urlsafe
  4. from urllib.parse import quote, urlencode
  5. import uuid
  6. from django.conf import settings
  7. from django.contrib.auth.hashers import check_password, make_password
  8. from django.contrib.auth.models import User
  9. from django.core.signing import TimestampSigner
  10. from django.db import models
  11. from django.db.models import Count, Q
  12. from django.urls import reverse
  13. from django.utils import timezone
  14. from fido2.ctap2 import AttestedCredentialData
  15. from hc.lib import emails
  16. from hc.lib.date import month_boundaries
  17. import pytz
  18. NO_NAG = timedelta()
  19. NAG_PERIODS = (
  20. (NO_NAG, "Disabled"),
  21. (timedelta(hours=1), "Hourly"),
  22. (timedelta(days=1), "Daily"),
  23. )
  24. REPORT_CHOICES = (("off", "Off"), ("weekly", "Weekly"), ("monthly", "Monthly"))
  25. def month(dt):
  26. """ For a given datetime, return the matching first-day-of-month date. """
  27. return dt.date().replace(day=1)
  28. class ProfileManager(models.Manager):
  29. def for_user(self, user):
  30. try:
  31. return user.profile
  32. except Profile.DoesNotExist:
  33. profile = Profile(user=user)
  34. if not settings.USE_PAYMENTS:
  35. # If not using payments, set high limits
  36. profile.check_limit = 500
  37. profile.sms_limit = 500
  38. profile.call_limit = 500
  39. profile.team_limit = 500
  40. profile.save()
  41. return profile
  42. class Profile(models.Model):
  43. user = models.OneToOneField(User, models.CASCADE, blank=True, null=True)
  44. next_report_date = models.DateTimeField(null=True, blank=True)
  45. reports = models.CharField(max_length=10, default="monthly", choices=REPORT_CHOICES)
  46. nag_period = models.DurationField(default=NO_NAG, choices=NAG_PERIODS)
  47. next_nag_date = models.DateTimeField(null=True, blank=True)
  48. ping_log_limit = models.IntegerField(default=100)
  49. check_limit = models.IntegerField(default=20)
  50. token = models.CharField(max_length=128, blank=True)
  51. last_sms_date = models.DateTimeField(null=True, blank=True)
  52. sms_limit = models.IntegerField(default=5)
  53. sms_sent = models.IntegerField(default=0)
  54. last_call_date = models.DateTimeField(null=True, blank=True)
  55. call_limit = models.IntegerField(default=0)
  56. calls_sent = models.IntegerField(default=0)
  57. team_limit = models.IntegerField(default=2)
  58. sort = models.CharField(max_length=20, default="created")
  59. deletion_notice_date = models.DateTimeField(null=True, blank=True)
  60. last_active_date = models.DateTimeField(null=True, blank=True)
  61. tz = models.CharField(max_length=36, default="UTC")
  62. theme = models.CharField(max_length=10, null=True, blank=True)
  63. totp = models.CharField(max_length=32, null=True, blank=True)
  64. totp_created = models.DateTimeField(null=True, blank=True)
  65. objects = ProfileManager()
  66. def __str__(self):
  67. return "Profile for %s" % self.user.email
  68. def notifications_url(self):
  69. return settings.SITE_ROOT + reverse("hc-notifications")
  70. def reports_unsub_url(self):
  71. signer = TimestampSigner(salt="reports")
  72. signed_username = signer.sign(self.user.username)
  73. path = reverse("hc-unsubscribe-reports", args=[signed_username])
  74. return settings.SITE_ROOT + path
  75. def prepare_token(self, salt):
  76. token = token_urlsafe(24)
  77. self.token = make_password(token, salt)
  78. self.save()
  79. return token
  80. def check_token(self, token, salt):
  81. return salt in self.token and check_password(token, self.token)
  82. def send_instant_login_link(self, inviting_project=None, redirect_url=None):
  83. token = self.prepare_token("login")
  84. path = reverse("hc-check-token", args=[self.user.username, token])
  85. if redirect_url:
  86. path += "?next=%s" % redirect_url
  87. ctx = {
  88. "button_text": "Sign In",
  89. "button_url": settings.SITE_ROOT + path,
  90. "inviting_project": inviting_project,
  91. }
  92. emails.login(self.user.email, ctx)
  93. def send_transfer_request(self, project):
  94. token = self.prepare_token("login")
  95. settings_path = reverse("hc-project-settings", args=[project.code])
  96. path = reverse("hc-check-token", args=[self.user.username, token])
  97. path += "?next=%s" % settings_path
  98. ctx = {
  99. "button_text": "Project Settings",
  100. "button_url": settings.SITE_ROOT + path,
  101. "project": project,
  102. }
  103. emails.transfer_request(self.user.email, ctx)
  104. def send_sms_limit_notice(self, transport):
  105. ctx = {"transport": transport, "limit": self.sms_limit}
  106. if self.sms_limit != 500 and settings.USE_PAYMENTS:
  107. ctx["url"] = settings.SITE_ROOT + reverse("hc-pricing")
  108. emails.sms_limit(self.user.email, ctx)
  109. def send_call_limit_notice(self):
  110. ctx = {"limit": self.call_limit}
  111. if self.call_limit != 500 and settings.USE_PAYMENTS:
  112. ctx["url"] = settings.SITE_ROOT + reverse("hc-pricing")
  113. emails.call_limit(self.user.email, ctx)
  114. def projects(self):
  115. """ Return a queryset of all projects we have access to. """
  116. is_owner = Q(owner_id=self.user_id)
  117. is_member = Q(member__user_id=self.user_id)
  118. q = Project.objects.filter(is_owner | is_member)
  119. return q.distinct().order_by("name")
  120. def annotated_projects(self):
  121. """ Return all projects, annotated with 'n_down'. """
  122. # Subquery for getting project ids
  123. project_ids = self.projects().values("id")
  124. # Main query with the n_down annotation.
  125. # Must use the subquery, otherwise ORM gets confused by
  126. # joins and group by's
  127. q = Project.objects.filter(id__in=project_ids)
  128. n_down = Count("check", filter=Q(check__status="down"))
  129. q = q.annotate(n_down=n_down)
  130. return q.order_by("name")
  131. def checks_from_all_projects(self):
  132. """ Return a queryset of checks from projects we have access to. """
  133. project_ids = self.projects().values("id")
  134. from hc.api.models import Check
  135. return Check.objects.filter(project_id__in=project_ids)
  136. def send_report(self, nag=False):
  137. checks = self.checks_from_all_projects()
  138. # Has there been a ping in last 6 months?
  139. result = checks.aggregate(models.Max("last_ping"))
  140. last_ping = result["last_ping__max"]
  141. six_months_ago = timezone.now() - timedelta(days=180)
  142. if last_ping is None or last_ping < six_months_ago:
  143. return False
  144. # Is there at least one check that is down?
  145. num_down = checks.filter(status="down").count()
  146. if nag and num_down == 0:
  147. return False
  148. # Sort checks by project. Need this because will group by project in
  149. # template.
  150. checks = checks.select_related("project")
  151. checks = checks.order_by("project_id")
  152. # list() executes the query, to avoid DB access while
  153. # rendering the template
  154. checks = list(checks)
  155. unsub_url = self.reports_unsub_url()
  156. headers = {
  157. "List-Unsubscribe": "<%s>" % unsub_url,
  158. "X-Bounce-Url": unsub_url,
  159. "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
  160. }
  161. boundaries = month_boundaries(months=3)
  162. # throw away the current month, keep two previous months
  163. boundaries.pop()
  164. ctx = {
  165. "checks": checks,
  166. "sort": self.sort,
  167. "now": timezone.now(),
  168. "unsub_link": unsub_url,
  169. "notifications_url": self.notifications_url(),
  170. "nag": nag,
  171. "nag_period": self.nag_period.total_seconds(),
  172. "num_down": num_down,
  173. "month_boundaries": boundaries,
  174. "monthly_or_weekly": self.reports,
  175. }
  176. emails.report(self.user.email, ctx, headers)
  177. return True
  178. def sms_sent_this_month(self):
  179. # IF last_sms_date was never set, we have not sent any messages yet.
  180. if not self.last_sms_date:
  181. return 0
  182. # If last sent date is not from this month, we've sent 0 this month.
  183. if month(timezone.now()) > month(self.last_sms_date):
  184. return 0
  185. return self.sms_sent
  186. def authorize_sms(self):
  187. """ If monthly limit not exceeded, increase counter and return True """
  188. sent_this_month = self.sms_sent_this_month()
  189. if sent_this_month >= self.sms_limit:
  190. return False
  191. self.sms_sent = sent_this_month + 1
  192. self.last_sms_date = timezone.now()
  193. self.save()
  194. return True
  195. def calls_sent_this_month(self):
  196. # IF last_call_date was never set, we have not made any phone calls yet.
  197. if not self.last_call_date:
  198. return 0
  199. # If last sent date is not from this month, we've made 0 calls this month.
  200. if month(timezone.now()) > month(self.last_call_date):
  201. return 0
  202. return self.calls_sent
  203. def authorize_call(self):
  204. """ If monthly limit not exceeded, increase counter and return True """
  205. sent_this_month = self.calls_sent_this_month()
  206. if sent_this_month >= self.call_limit:
  207. return False
  208. self.calls_sent = sent_this_month + 1
  209. self.last_call_date = timezone.now()
  210. self.save()
  211. return True
  212. def num_checks_used(self):
  213. from hc.api.models import Check
  214. return Check.objects.filter(project__owner_id=self.user_id).count()
  215. def num_checks_available(self):
  216. return self.check_limit - self.num_checks_used()
  217. def can_accept(self, project):
  218. return project.num_checks() <= self.num_checks_available()
  219. def update_next_nag_date(self):
  220. any_down = self.checks_from_all_projects().filter(status="down").exists()
  221. if any_down and self.next_nag_date is None and self.nag_period:
  222. self.next_nag_date = timezone.now() + self.nag_period
  223. self.save(update_fields=["next_nag_date"])
  224. elif not any_down and self.next_nag_date:
  225. self.next_nag_date = None
  226. self.save(update_fields=["next_nag_date"])
  227. def choose_next_report_date(self):
  228. """ Calculate the target date for the next monthly/weekly report.
  229. Monthly reports should get sent on 1st of each month, between
  230. 9AM and 11AM in user's timezone.
  231. Weekly reports should get sent on Mondays, between
  232. 9AM and 11AM in user's timezone.
  233. """
  234. if self.reports == "off":
  235. return None
  236. tz = pytz.timezone(self.tz)
  237. dt = timezone.now().astimezone(tz)
  238. dt = dt.replace(hour=9, minute=0) + timedelta(minutes=random.randrange(0, 120))
  239. while True:
  240. dt += timedelta(days=1)
  241. if self.reports == "monthly" and dt.day == 1:
  242. return dt
  243. elif self.reports == "weekly" and dt.weekday() == 0:
  244. return dt
  245. class Project(models.Model):
  246. code = models.UUIDField(default=uuid.uuid4, unique=True)
  247. name = models.CharField(max_length=200, blank=True)
  248. owner = models.ForeignKey(User, models.CASCADE)
  249. api_key = models.CharField(max_length=128, blank=True, db_index=True)
  250. api_key_readonly = models.CharField(max_length=128, blank=True, db_index=True)
  251. badge_key = models.CharField(max_length=150, unique=True)
  252. def __str__(self):
  253. return self.name or self.owner.email
  254. @property
  255. def owner_profile(self):
  256. return Profile.objects.for_user(self.owner)
  257. def num_checks(self):
  258. return self.check_set.count()
  259. def num_checks_available(self):
  260. return self.owner_profile.num_checks_available()
  261. def set_api_keys(self):
  262. self.api_key = token_urlsafe(nbytes=24)
  263. self.api_key_readonly = token_urlsafe(nbytes=24)
  264. self.save()
  265. def invite_suggestions(self):
  266. q = User.objects.filter(memberships__project__owner_id=self.owner_id)
  267. q = q.exclude(memberships__project=self)
  268. return q.distinct().order_by("email")
  269. def can_invite_new_users(self):
  270. q = User.objects.filter(memberships__project__owner_id=self.owner_id)
  271. used = q.distinct().count()
  272. return used < self.owner_profile.team_limit
  273. def invite(self, user, role):
  274. if Member.objects.filter(user=user, project=self).exists():
  275. return False
  276. if self.owner_id == user.id:
  277. return False
  278. Member.objects.create(user=user, project=self, role=role)
  279. checks_url = reverse("hc-checks", args=[self.code])
  280. user.profile.send_instant_login_link(self, redirect_url=checks_url)
  281. return True
  282. def update_next_nag_dates(self):
  283. """ Update next_nag_date on profiles of all members of this project. """
  284. is_owner = Q(user_id=self.owner_id)
  285. is_member = Q(user__memberships__project=self)
  286. q = Profile.objects.filter(is_owner | is_member).exclude(nag_period=NO_NAG)
  287. for profile in q:
  288. profile.update_next_nag_date()
  289. def overall_status(self):
  290. if not hasattr(self, "_overall_status"):
  291. self._overall_status = "up"
  292. for check in self.check_set.all():
  293. check_status = check.get_status()
  294. if check_status == "grace" and self._overall_status == "up":
  295. self._overall_status = "grace"
  296. elif check_status == "down":
  297. self._overall_status = "down"
  298. break
  299. return self._overall_status
  300. def get_n_down(self):
  301. result = 0
  302. for check in self.check_set.all():
  303. if check.get_status() == "down":
  304. result += 1
  305. return result
  306. def have_channel_issues(self):
  307. errors = list(self.channel_set.values_list("last_error", flat=True))
  308. # It's a problem if a project has no integrations at all
  309. if len(errors) == 0:
  310. return True
  311. # It's a problem if any integration has a logged error
  312. return True if max(errors) else False
  313. def transfer_request(self):
  314. return self.member_set.filter(transfer_request_date__isnull=False).first()
  315. def dashboard_url(self):
  316. if not self.api_key_readonly:
  317. return None
  318. frag = urlencode({self.api_key_readonly: str(self)}, quote_via=quote)
  319. return reverse("hc-dashboard") + "#" + frag
  320. def checks_url(self):
  321. return settings.SITE_ROOT + reverse("hc-checks", args=[self.code])
  322. class Member(models.Model):
  323. class Role(models.TextChoices):
  324. READONLY = "r", "Read-only"
  325. REGULAR = "w", "Member"
  326. MANAGER = "m", "Manager"
  327. user = models.ForeignKey(User, models.CASCADE, related_name="memberships")
  328. project = models.ForeignKey(Project, models.CASCADE)
  329. transfer_request_date = models.DateTimeField(null=True, blank=True)
  330. role = models.CharField(max_length=1, default=Role.REGULAR, choices=Role.choices)
  331. class Meta:
  332. constraints = [
  333. models.UniqueConstraint(
  334. fields=["user", "project"], name="accounts_member_no_duplicates"
  335. )
  336. ]
  337. def can_accept(self):
  338. return self.user.profile.can_accept(self.project)
  339. @property
  340. def is_rw(self):
  341. return self.role in (Member.Role.REGULAR, Member.Role.MANAGER)
  342. class Credential(models.Model):
  343. code = models.UUIDField(default=uuid.uuid4, unique=True)
  344. name = models.CharField(max_length=100)
  345. user = models.ForeignKey(User, models.CASCADE, related_name="credentials")
  346. created = models.DateTimeField(auto_now_add=True)
  347. data = models.BinaryField()
  348. def unpack(self):
  349. unpacked, remaining_data = AttestedCredentialData.unpack_from(self.data)
  350. return unpacked