forked from GithubBackups/healthchecks
Compare commits
1122 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
c196dc16d7 | ||
|
b43612806f | ||
|
544ec7ea69 | ||
|
78113e1aea | ||
|
74f56a5501 | ||
|
2a9bc42dd4 | ||
|
af7e8fc949 | ||
|
7252f2f101 | ||
|
f85aec225d | ||
|
e6427995b7 | ||
|
ca3afa33f9 | ||
|
f3af13654e | ||
|
fca600659d | ||
|
c3d458f6f0 | ||
|
934099510d | ||
|
d60d8a43b6 | ||
|
8ed5e93cd2 | ||
|
222722569e | ||
|
0d9d094882 | ||
|
dfa6f404e6 | ||
|
bbd2786e0f | ||
|
74427ba3f1 | ||
|
e1c3beb4e9 | ||
|
4f83f8c06b | ||
|
9640d2242f | ||
|
ce9ff3ac42 | ||
|
cb799dbd29 | ||
|
936a5213f8 | ||
|
d19cb8c681 | ||
|
5230dbb425 | ||
|
e46000ecdf | ||
|
79dc4d2e7a | ||
|
02cdbb9222 | ||
|
94c5ea3e13 | ||
|
2382bf6722 | ||
|
dd88924660 | ||
|
b75b062559 | ||
|
e186d039fc | ||
|
2271a4dbb0 | ||
|
99bb71c920 | ||
|
5c54afadb5 | ||
|
c94e39c9d3 | ||
|
92a9910092 | ||
|
0e7252d8fa | ||
|
5a4c06ffae | ||
|
92ef81c0a5 | ||
|
83eb10b99e | ||
|
ec56ceae8f | ||
|
d243f502d3 | ||
|
760c3757f3 | ||
|
c2d8166e74 | ||
|
61a8a8de26 | ||
|
b70e2c9a25 | ||
|
8a154cbaf5 | ||
|
52e1d420b5 | ||
|
1c02d1ff87 | ||
|
c75d17618c | ||
|
93a881d0ba | ||
|
fdfd988c5c | ||
|
6e01af3327 | ||
|
2d20f439dd | ||
|
059a855b3f | ||
|
b185a28676 | ||
|
6c10980889 | ||
|
13334d2ab0 | ||
|
4f72c9e204 | ||
|
dd104ff672 | ||
|
c5229d6505 | ||
|
fd7ab5e767 | ||
|
2cd2bfed6f | ||
|
a0cd2c63e9 | ||
|
8ce09ab9e5 | ||
|
548b2ac33c | ||
|
6094bca241 | ||
|
fa5dd8b45a | ||
|
df44ee58c0 | ||
|
03a538c5e2 | ||
|
5ca7262164 | ||
|
82dc6844ae | ||
|
ac83bf8896 | ||
|
32ca8b3420 | ||
|
3e207e538a | ||
|
e91441d814 | ||
|
855d188981 | ||
|
e090aa5403 | ||
|
94416c90dc | ||
|
ae4487b6c3 | ||
|
64f2e86051 | ||
|
aa71629ffc | ||
|
599f481d58 | ||
|
a36c326e32 | ||
|
e2b96d9bd8 | ||
|
6ed983cdd5 | ||
|
6d2c67338c | ||
|
6c8b6a2a19 | ||
|
738a648407 | ||
|
4587b45cab | ||
|
2831e5d7c1 | ||
|
742af7bfd8 | ||
|
78652b5659 | ||
|
67d11e8d40 | ||
|
aa7ef5e9bb | ||
|
68b1d5bb8b | ||
|
1d6b75d5dc | ||
|
05db43f95d | ||
|
7ba5fcbb71 | ||
|
502ff7567e | ||
|
a101b1de91 | ||
|
57336187a7 | ||
|
ad886fe157 | ||
|
0c58a9b9ee | ||
|
e2576607f5 | ||
|
e66725e23f | ||
|
9fd9c8e4ef | ||
|
2bfea987e9 | ||
|
5321f772fe | ||
|
448721e916 | ||
|
1d62176f34 | ||
|
d4fc314696 | ||
|
46bc7d8306 | ||
|
2a63d24812 | ||
|
1bc89f0d5d | ||
|
18b39a5a79 | ||
|
44a677f327 | ||
|
1e84cac37d | ||
|
f06616a934 | ||
|
6cd3f0e35a | ||
|
68e19c938e | ||
|
438ae0264e | ||
|
474d782869 | ||
|
c1f433bb71 | ||
|
5979204691 | ||
|
0a0b48a3fe | ||
|
b788c7e4f5 | ||
|
67560c96e1 | ||
|
dc9fcfa0ab | ||
|
65ace8238a | ||
|
e2c90c05b8 | ||
|
205f1ccce6 | ||
|
a5ea8a03c6 | ||
|
238d0b8ff1 | ||
|
8811640d45 | ||
|
725be65bdd | ||
|
419d96da7a | ||
|
28150e85fa | ||
|
8d5890d883 | ||
|
5f31b8b873 | ||
|
6c3debaf11 | ||
|
52435a9a0c | ||
|
67ff8a9bee | ||
|
45078e6566 | ||
|
dc39831aef | ||
|
59ebcb963f | ||
|
4e480cac57 | ||
|
c2bb4b31b5 | ||
|
ae976a38b6 | ||
|
b9997137a6 | ||
|
98b1e13aa1 | ||
|
168f8b0bc6 | ||
|
5ee0ef6381 | ||
|
1419da460e | ||
|
35e6d41793 | ||
|
a763fa1de3 | ||
|
98439623c5 | ||
|
601d8fac94 | ||
|
376d80afd4 | ||
|
7e6afba8bd | ||
|
b7c769fc0e | ||
|
fbefcbc0ed | ||
|
94abe0fbb5 | ||
|
5540fc2c6d | ||
|
d45dc2f6a3 | ||
|
9a0888aacd | ||
|
3b6afae140 | ||
|
1e46cd6e93 | ||
|
d7c7ae6531 | ||
|
ce7e32ac03 | ||
|
74ed15e0aa | ||
|
a80b831eea | ||
|
d4aac691ce | ||
|
ee37d305ef | ||
|
f607ee67d5 | ||
|
0aeef7d06e | ||
|
55a22e5043 | ||
|
847a610af9 | ||
|
cd99af14ba | ||
|
959df1ffaa | ||
|
17a404f04b | ||
|
599f35e4f0 | ||
|
bf3df906f7 | ||
|
54081208c5 | ||
|
efc44fd47c | ||
|
ca3d1d3a3b | ||
|
26a7918b5b | ||
|
02b5ec3657 | ||
|
70519fcd89 | ||
|
8fa0d04830 | ||
|
1f641962d2 | ||
|
ce0f84a112 | ||
|
8fe8e0f605 | ||
|
c3b6d40012 | ||
|
823b3dbc7b | ||
|
77a5f11cf9 | ||
|
0f1abd3498 | ||
|
b8f1bdaf96 | ||
|
dfd159ab18 | ||
|
54a95a0ee2 | ||
|
5e3e371661 | ||
|
70ef9c1904 | ||
|
ea6d04d061 | ||
|
5d650f07fb | ||
|
9623e3eacb | ||
|
ec40082550 | ||
|
617bd92434 | ||
|
524d1a7375 | ||
|
dd45c888a7 | ||
|
b9abcbcdee | ||
|
62fcd30ce8 | ||
|
eed7ef36d1 | ||
|
0b4251bdee | ||
|
c8d387aee4 | ||
|
3cfc31610a | ||
|
8448f882cf | ||
|
568a287850 | ||
|
8dbf9e02af | ||
|
7124383a53 | ||
|
9401bc3987 | ||
|
48750ee668 | ||
|
fb79948759 | ||
|
ed6b15bfa9 | ||
|
1ca4caa3a8 | ||
|
adb7702f39 | ||
|
7639f0dd69 | ||
|
d0f327b213 | ||
|
839c309cf7 | ||
|
155a1f132b | ||
|
155226d82a | ||
|
ecf964ea3b | ||
|
9f58ebfd3e | ||
|
64be87137b | ||
|
2ac0f87560 | ||
|
42497fe91a | ||
|
2c3286c280 | ||
|
e3aedd3b03 | ||
|
03ea725612 | ||
|
53688f1d87 | ||
|
1eaa216d3a | ||
|
cdd2e98bd0 | ||
|
816c158744 | ||
|
d5502c50ca | ||
|
0b685e8b5a | ||
|
f7e004b2ea | ||
|
81e59ac553 | ||
|
6f56ed7f92 | ||
|
078577cbb7 | ||
|
a37e83aca8 | ||
|
7534f1856f | ||
|
7e56156d32 | ||
|
0e77064c44 | ||
|
20008a1d7e | ||
|
71d7b46379 | ||
|
a10215ce65 | ||
|
463ec8c988 | ||
|
63beeb05a1 | ||
|
a13b44284e | ||
|
1967c712ca | ||
|
fd8da1b642 | ||
|
05c81e0a41 | ||
|
b64c8d1cb8 | ||
|
c13f65e118 | ||
|
b4729cdb57 | ||
|
e63aa9fe8d | ||
|
66a1a108bf | ||
|
bd98174d4c | ||
|
0f0930fbf5 | ||
|
c84626040c | ||
|
0362df55ba | ||
|
ad720af242 | ||
|
5ebb5958ea | ||
|
9ba9032389 | ||
|
d1b1a6c02e | ||
|
4f53325730 | ||
|
25a8ec6bd9 | ||
|
b4ba582255 | ||
|
ae578a29c2 | ||
|
a2c123c74b | ||
|
0a85c5ed12 | ||
|
e424176a1f | ||
|
b2a1c0d343 | ||
|
d73de68f70 | ||
|
adb004b333 | ||
|
39198c827a | ||
|
24c34430ac | ||
|
bdf99e0ea7 | ||
|
c9baa2d8eb | ||
|
024d0adb9c | ||
|
cbd7ffbffb | ||
|
11d8e6197c | ||
|
00790dc33c | ||
|
84cc33412a | ||
|
40f95d5a56 | ||
|
a5e1343a66 | ||
|
dd5ca9d783 | ||
|
11c02d89c1 | ||
|
33639964b8 | ||
|
94b993354f | ||
|
f15e16a0bb | ||
|
74668551a7 | ||
|
65b65188d1 | ||
|
2346ac3e80 | ||
|
9a1127005e | ||
|
b7e2404f98 | ||
|
c75a37570e | ||
|
c7af52637a | ||
|
8ea510cda6 | ||
|
a29b82a0ed | ||
|
697cb19bde | ||
|
ffafc16fe5 | ||
|
8223b0c402 | ||
|
77f81b82e7 | ||
|
cef71b1159 | ||
|
99b0786f19 | ||
|
b63f3bed8e | ||
|
f131123e0e | ||
|
96d458fcf3 | ||
|
c476f042ba | ||
|
ae01c7a9d1 | ||
|
95d58d26d5 | ||
|
750b96c374 | ||
|
9edb8aa08d | ||
|
2ed9a8fd30 | ||
|
732df19374 | ||
|
d05691f86f | ||
|
8c13457037 | ||
|
77ee8452c5 | ||
|
ee9ac0ffef | ||
|
43e56ce788 | ||
|
5acea4c89d | ||
|
1ff7b2c581 | ||
|
ce50755314 | ||
|
ea896c907f | ||
|
fd14e0e03b | ||
|
519a666057 | ||
|
0d03e3f00b | ||
|
556e8c67c5 | ||
|
59e566117b | ||
|
6834adf878 | ||
|
028e131327 | ||
|
589c0c0363 | ||
|
f814035f03 | ||
|
255d4e7bb7 | ||
|
ec5ee03a3e | ||
|
f789cad2af | ||
|
f5ceb612e0 | ||
|
80fdfbfa59 | ||
|
62fe42e953 | ||
|
58f16da935 | ||
|
1f978ff80e | ||
|
6300947c77 | ||
|
d34854f838 | ||
|
911293e1d2 | ||
|
3f44eac485 | ||
|
c160045bda | ||
|
d6c0d9722b | ||
|
2510e387e6 | ||
|
4324843c41 | ||
|
2cb0ac907d | ||
|
e89229a2ca | ||
|
27a91bfe22 | ||
|
df65ec9d89 | ||
|
f573578108 | ||
|
3a00c0d2aa | ||
|
ae4918db86 | ||
|
1e53027b84 | ||
|
5b3928ce79 | ||
|
192e72c243 | ||
|
3c461473ec | ||
|
634b525d1a | ||
|
0b5fa40f68 | ||
|
149096811d | ||
|
a18eb134f5 | ||
|
eccc193b87 | ||
|
a3b58d25ff | ||
|
bd3f150284 | ||
|
84889d6160 | ||
|
276c36841a | ||
|
5ab09f61f7 | ||
|
c3d8ee0965 | ||
|
832580f343 | ||
|
60d1c6e2a3 | ||
|
a90f8a3a56 | ||
|
f9c10d99c1 | ||
|
368d7a4fec | ||
|
c11526a05d | ||
|
bc0684df63 | ||
|
c5c4e0f782 | ||
|
7d625cb6a6 | ||
|
90d4246848 | ||
|
4b1b232959 | ||
|
bc6ccd55b3 | ||
|
aaadf6031f | ||
|
4592987810 | ||
|
beff11ceff | ||
|
01fafd9908 | ||
|
cdafc06c65 | ||
|
8725c81144 | ||
|
fd4d59c4e1 | ||
|
0e5d578360 | ||
|
a07325e40f | ||
|
461ef5e088 | ||
|
8e51d26595 | ||
|
ffc45f0c74 | ||
|
4f1f06e29f | ||
|
b2175c9260 | ||
|
3eebd8968d | ||
|
5c8b5b7b63 | ||
|
cfb294862f | ||
|
95279f6f3f | ||
|
9617be6e1b | ||
|
c70a2588c6 | ||
|
b433e91b48 | ||
|
eb279c4c21 | ||
|
3730c67c80 | ||
|
98310eeeaa | ||
|
edbfd4b437 | ||
|
7994259003 | ||
|
fbd8419700 | ||
|
9bfdbc4214 | ||
|
385021b44c | ||
|
e04a92ccf1 | ||
|
3cca17560a | ||
|
00ea45655d | ||
|
825110a354 | ||
|
abdff95ce8 | ||
|
c057dbfb2c | ||
|
6ede17d93f | ||
|
d6bb2b5435 | ||
|
34807dc5aa | ||
|
dda08a6143 | ||
|
4331497ccd | ||
|
38382d662d | ||
|
ca715dd8d4 | ||
|
57da17b8e2 | ||
|
da954000fd | ||
|
3bf1ad9746 | ||
|
532b752e3c | ||
|
f7acaa57af | ||
|
f42b2b144a | ||
|
cb19bac70f | ||
|
4e0460c69b | ||
|
a982ad7123 | ||
|
f1880657fd | ||
|
733c589e47 | ||
|
8c7d3570a5 | ||
|
c596f485a5 | ||
|
92542fa818 | ||
|
609f78c5ed | ||
|
f12a649c72 | ||
|
a1791ea404 | ||
|
56bb49f1f3 | ||
|
74f4744c62 | ||
|
010bbc9507 | ||
|
9d2cf4f008 | ||
|
4a43ed59fc | ||
|
76ae42bc8f | ||
|
5a297ba6a2 | ||
|
f1750a5f6e | ||
|
119965b432 | ||
|
1baa8ad46d | ||
|
abebdca527 | ||
|
da4cf5241e | ||
|
ffc7ccddf2 | ||
|
613ef2d0cf | ||
|
456a80f1fa | ||
|
6373db8aa1 | ||
|
9c9be4f181 | ||
|
25d7d5409f | ||
|
5f2c20e46b | ||
|
8c7f3977e2 | ||
|
c9979cc125 | ||
|
50118d90c5 | ||
|
b689c8aa4e | ||
|
f352efdd5f | ||
|
1cb2ec16fb | ||
|
5d513658e3 | ||
|
bf1294a100 | ||
|
ab692236eb | ||
|
26ad94d068 | ||
|
c8ebf73058 | ||
|
6147451851 | ||
|
3e25e5c242 | ||
|
75cb95ffec | ||
|
fcf11d5b4f | ||
|
eb7f51f6f5 | ||
|
00810ff123 | ||
|
35e476be59 | ||
|
2e30d349aa | ||
|
db9593c571 | ||
|
ccba5e8731 | ||
|
dab0c4200e | ||
|
516143de8a | ||
|
22ef024885 | ||
|
8bbf85a397 | ||
|
dd3820c0d5 | ||
|
4bcfba728e | ||
|
d3ee9bae0e | ||
|
490362638f | ||
|
dab15c3b8c | ||
|
29e016d0fc | ||
|
0c9c453ea0 | ||
|
93b48ce720 | ||
|
9389408cbc | ||
|
dc373dc054 | ||
|
b5b5c58d77 | ||
|
157711bc95 | ||
|
6a0c90853b | ||
|
9c3f7101db | ||
|
bb808852d9 | ||
|
318934697f | ||
|
f2375f9f45 | ||
|
7060d49306 | ||
|
acce0808ce | ||
|
dee189be33 | ||
|
26757c6785 | ||
|
f6f2b18c5d | ||
|
ea333f7ac1 | ||
|
f13ad875a1 | ||
|
d88f99a712 | ||
|
38bd84cc91 | ||
|
44819cb555 | ||
|
81f9a604e1 | ||
|
88f2a01182 | ||
|
056134f2de | ||
|
9f5c133719 | ||
|
250935006d | ||
|
f6a7d46058 | ||
|
5fb5b05f2e | ||
|
1f950feee1 | ||
|
0ea2369dc0 | ||
|
a6d497b21e | ||
|
d0b77febbc | ||
|
70ff6c53e4 | ||
|
f8758e39ea | ||
|
59f5b7a5f5 | ||
|
ea423e5420 | ||
|
9e82cbb412 | ||
|
99bdc0ec8c | ||
|
b5a4dada43 | ||
|
5e051d53f8 | ||
|
cde1f50ac2 | ||
|
fb527e4ed8 | ||
|
435659166c | ||
|
7a0f3421dd | ||
|
3092eaf88d | ||
|
e52ac9af91 | ||
|
12b946acf3 | ||
|
0ff4bd01e0 | ||
|
683dda9c5d | ||
|
82d61335b0 | ||
|
174e5a7935 | ||
|
15b9611c5a | ||
|
c3608ac07c | ||
|
8ace6d5481 | ||
|
ff383729cf | ||
|
c8ccd89af2 | ||
|
b0b6ee3149 | ||
|
ccd30ac239 | ||
|
4f6f1d9f66 | ||
|
ac4f1ca059 | ||
|
4a7074418a | ||
|
1b8460f39f | ||
|
50280875cd | ||
|
9f2638bf72 | ||
|
5d9944873c | ||
|
6bc4948d00 | ||
|
3048a20f9b | ||
|
b1bffde3d6 | ||
|
e29b2387de | ||
|
272360336b | ||
|
1e721b8bcd | ||
|
4bdc893fe0 | ||
|
5433cb1798 | ||
|
b7d6f1bb30 | ||
|
f51a0a257e | ||
|
b8cf428899 | ||
|
b91f11588c | ||
|
319d4528bb | ||
|
b8c0fd0eb9 | ||
|
e2fe2edcc1 | ||
|
564f69aca5 | ||
|
d29b0050a3 | ||
|
dbd21c325d | ||
|
d7de6476b7 | ||
|
a276c24dd3 | ||
|
74ab0d1931 | ||
|
3e2ae02388 | ||
|
f41c78e40f | ||
|
50c8c153ea | ||
|
7cf324872c | ||
|
cdad632082 | ||
|
77033760f9 | ||
|
58a118c494 | ||
|
eae8d122b7 | ||
|
96797f6786 | ||
|
819aa227e9 | ||
|
b8108906f4 | ||
|
c521b44d20 | ||
|
74ad152cc5 | ||
|
c4edb415a2 | ||
|
012ad88bb3 | ||
|
4ee2646539 | ||
|
8e455965c4 | ||
|
52a178242b | ||
|
18154dd6de | ||
|
3649c500d2 | ||
|
38ed309a3c | ||
|
84a4de32cc | ||
|
6ebae33579 | ||
|
be286518b7 | ||
|
057a6fe56b | ||
|
830681d8f8 | ||
|
0d2c6217d3 | ||
|
66c9fb33ad | ||
|
d9776e1340 | ||
|
bffb51357e | ||
|
9697fc1b45 | ||
|
1b3d7e8c0a | ||
|
d6be955fa7 | ||
|
15ba415298 | ||
|
01bb03c889 | ||
|
b72979522b | ||
|
2a8e7ee766 | ||
|
eafff677d9 | ||
|
f7496fb8cf | ||
|
0addbac7ba | ||
|
8d81d27af3 | ||
|
4ee92a44ff | ||
|
f9c61dad23 | ||
|
1cdb6e6d1d | ||
|
22d4d55340 | ||
|
838aee6bdd | ||
|
5f47161e5e | ||
|
87b232074c | ||
|
7b32e9ef2c | ||
|
da095f2403 | ||
|
3f19181028 | ||
|
87d75505fe | ||
|
25f959c44b | ||
|
89a5fbb7f9 | ||
|
2893e370b6 | ||
|
1b005b6a9f | ||
|
5ab8486788 | ||
|
0349a3997b | ||
|
f6d36b3491 | ||
|
d06721ab58 | ||
|
7c1b9c4b96 | ||
|
01955e4f99 | ||
|
98ba51f44f | ||
|
e4646205cb | ||
|
fbba2b585e | ||
|
5556bf3035 | ||
|
c54c70cab7 | ||
|
91c93b6a95 | ||
|
8d81ea8f9d | ||
|
f74860bc0c | ||
|
494fd9ffb7 | ||
|
84bc6e7b2c | ||
|
2b4de95141 | ||
|
dc84b7be01 | ||
|
046a643b13 | ||
|
9cbd3bfc5a | ||
|
052700a642 | ||
|
87495a74c6 | ||
|
05855c1c69 | ||
|
7904908625 | ||
|
a464154151 | ||
|
7db11fa7aa | ||
|
c13ec18a27 | ||
|
2848076d87 | ||
|
3f36d31cde | ||
|
66a6de70c0 | ||
|
488ab2cce7 | ||
|
a5827c6458 | ||
|
82fb4ddece | ||
|
01fc8e423b | ||
|
1dea8b6050 | ||
|
4625196ded | ||
|
163b020116 | ||
|
2bb769f7bb | ||
|
391921d8af | ||
|
6cd4e494e8 | ||
|
ad731dfe0e | ||
|
fbc217ef35 | ||
|
3d9261c7c4 | ||
|
b0db5181d8 | ||
|
f9ec5b482f | ||
|
41a0871452 | ||
|
335c73d6a2 | ||
|
ca5e19fd2d | ||
|
accdfb637b | ||
|
34925f2cdf | ||
|
0d2736059d | ||
|
4755e1c9da | ||
|
5d8c5637b6 | ||
|
6e4e7d737f | ||
|
4f2b5772df | ||
|
7fffb95c96 | ||
|
c4bb20e3d5 | ||
|
69d4932194 | ||
|
0d924f4627 | ||
|
a0c7cbdfeb | ||
|
60defd6244 | ||
|
93507fcc47 | ||
|
339ac5e9d9 | ||
|
dfee69584b | ||
|
9474006d83 | ||
|
dfd449b101 | ||
|
dd83ec2214 | ||
|
6fce896e1a | ||
|
862bafc331 | ||
|
2489f86b38 | ||
|
fa16bd4e42 | ||
|
33dece4ad2 | ||
|
72d608902d | ||
|
4c39aeea83 | ||
|
dde2910c59 | ||
|
554f76e57a | ||
|
f1d7b4b39b | ||
|
ba886e90cb | ||
|
a99a009491 | ||
|
d70539b397 | ||
|
86ad70f6d5 | ||
|
b5a03369b6 | ||
|
c2b1d00422 | ||
|
d39a1d5955 | ||
|
033d0ab197 | ||
|
c0d808271e | ||
|
b37d908879 | ||
|
b2ebce6cf9 | ||
|
b7320b1b69 | ||
|
cb2e763e98 | ||
|
1de0ef16f6 | ||
|
b74e56a273 | ||
|
e174e1ef4c | ||
|
429a69c2e9 | ||
|
e54aca6725 | ||
|
96c2cdbbb8 | ||
|
cc4f8b639b | ||
|
77fd0d00e0 | ||
|
35b137a8d7 | ||
|
e386ccaa0a | ||
|
5ab071ed56 | ||
|
1f1b1aedca | ||
|
3eef3c982f | ||
|
e0f161157d | ||
|
4867fab291 | ||
|
479208abf0 | ||
|
71dd8a31eb | ||
|
080e44f7ba | ||
|
3c0b9834e9 | ||
|
40f4adf78b | ||
|
5f0b02845e | ||
|
9dea24e937 | ||
|
c7eca1c4a7 | ||
|
d054970b02 | ||
|
8f6726d1ee | ||
|
78c9ee3e9e | ||
|
d5bae3d3d8 | ||
|
cdfc9840a7 | ||
|
1b948f4d5a | ||
|
0da7b12f55 | ||
|
14f504bd22 | ||
|
b528b23996 | ||
|
dd9fbc9e8c | ||
|
253f554591 | ||
|
ffa23b6504 | ||
|
90634610bb | ||
|
8f241f7da9 | ||
|
6040759601 | ||
|
b03e2852ff | ||
|
573c0b84d4 | ||
|
824caa7698 | ||
|
6c53719002 | ||
|
6327b951d5 | ||
|
d02a539a21 | ||
|
44bac9dd12 | ||
|
32ee6d4ca9 | ||
|
9f69dcb158 | ||
|
fcff4b48c6 | ||
|
23b197526c | ||
|
afaa8767cd | ||
|
beae8e62b4 | ||
|
d299feb420 | ||
|
3b3ae8a82c | ||
|
aaa3b2748e | ||
|
76479714a4 | ||
|
1cf08e0048 | ||
|
7ce612db6a | ||
|
ab86580b32 | ||
|
2a7129f8c8 | ||
|
4ecbe95cee | ||
|
e2c41b0c1f | ||
|
d682f79075 | ||
|
cdd8e57239 | ||
|
8802fdcfef | ||
|
a4fde44e3a | ||
|
499720a156 | ||
|
12f8ffcd80 | ||
|
e870dca0b5 | ||
|
9c41cf9732 | ||
|
f750c5e3fc | ||
|
dfc6c9fb1b | ||
|
0b90bb23ce | ||
|
178b0ff95c | ||
|
7c13adbf18 | ||
|
143c90674b | ||
|
67800782f8 | ||
|
0b26956a97 | ||
|
cf16b288cc | ||
|
73e5c651af | ||
|
740cda9507 | ||
|
d7e977d599 | ||
|
31d4582dc0 | ||
|
e3c4fe167a | ||
|
65d9180fb8 | ||
|
9b4c4482cb | ||
|
de1369f24e | ||
|
53467bd7d4 | ||
|
073bcb1f6f | ||
|
97ba6ad6b2 | ||
|
3a21f4e4c4 | ||
|
738aa8f1d6 | ||
|
5d368eb24e | ||
|
954d80b153 | ||
|
6f1ade98e2 | ||
|
af685205e6 | ||
|
0adc6497b8 | ||
|
945a66ab0a | ||
|
acd55ce7f3 | ||
|
cfb96304ca | ||
|
a0a766b500 | ||
|
620fd0eb98 | ||
|
68b818a89b | ||
|
212578c378 | ||
|
e294ae0e9b | ||
|
954ca4576b | ||
|
468321fcd7 | ||
|
37c9856e75 | ||
|
a95fec5ceb | ||
|
cb1b792d6c | ||
|
100bc3c5e7 | ||
|
290d46bc2a | ||
|
f8c0c20d34 | ||
|
f539e99652 | ||
|
15a853bd8a | ||
|
63b15d74a5 | ||
|
5cbd99cb5c | ||
|
3230cea1e3 | ||
|
836ea5afda | ||
|
c89a0b4e40 | ||
|
3c4027b788 | ||
|
c60a33874f | ||
|
5d9f19d2f9 | ||
|
8c4e7ad0b1 | ||
|
e65c29f28f | ||
|
b0f4bd3fce | ||
|
62310a5181 | ||
|
415ec58b95 | ||
|
886643db84 | ||
|
ae77f0bbd5 | ||
|
1b085a154b | ||
|
d04f3cc328 | ||
|
0cceeffcd8 | ||
|
c4c657f5d4 | ||
|
543a8c7c6a | ||
|
069bc9b735 | ||
|
02609ac05e | ||
|
282c3d39cb | ||
|
4ff1654806 | ||
|
c1e4595ab2 | ||
|
f2ae573872 | ||
|
08810d1fca | ||
|
8dd91b247b | ||
|
ac2a120ee8 | ||
|
6b0d566922 | ||
|
4e6fa38ec6 | ||
|
6d7942d7f9 | ||
|
8623e9c25a | ||
|
37337054c7 | ||
|
87919dbc5a | ||
|
28483020bf | ||
|
1a9137c0ac | ||
|
229e2a3922 | ||
|
f35f5893d8 | ||
|
eaf49f2367 | ||
|
b12eb1ee75 | ||
|
b013a92c43 | ||
|
64158c83a8 | ||
|
8eedf9d47b | ||
|
14c67bdaa9 | ||
|
7ecd0b606d | ||
|
a144bc762d | ||
|
d52d292889 | ||
|
70e467fb71 | ||
|
d403260e9b | ||
|
3c1964b493 | ||
|
a5a45db7a8 | ||
|
c42a854e75 | ||
|
e98bd42438 | ||
|
664aad916a | ||
|
77e3212956 | ||
|
0994006603 | ||
|
512c67a8f9 | ||
|
95dff3e799 | ||
|
caf6668478 | ||
|
e1b999e83a | ||
|
654516412e | ||
|
c08f02ab7f | ||
|
249cb8b82d | ||
|
c16e9dc4fe | ||
|
fc18652afa | ||
|
e2d2665edf | ||
|
fba8806e97 | ||
|
f357cd3305 | ||
|
82b644ae0a | ||
|
16bff94fab | ||
|
965998df18 | ||
|
34e54cca42 | ||
|
46c00e31a6 | ||
|
b5df5b3c6e | ||
|
d102f10a2d | ||
|
6b7f212c8a | ||
|
1c69cf7f89 | ||
|
b4635c69e7 | ||
|
5edcd42033 | ||
|
4f2930bb05 | ||
|
20df5843e3 | ||
|
99d46a0ca8 | ||
|
7480eca2a5 | ||
|
be4c4f7a26 | ||
|
179b085df4 | ||
|
0bdb0213e6 | ||
|
c225a67a3a | ||
|
5067014a19 | ||
|
da399e71b7 | ||
|
5dc4a879e4 | ||
|
67d72758be | ||
|
eb4e579a71 | ||
|
b3e290b284 | ||
|
e76329a364 | ||
|
eee6fc12f4 | ||
|
fe04429fad | ||
|
cc40793fc7 | ||
|
2f4b373e12 | ||
|
93405cc286 | ||
|
5f9ebb178c | ||
|
0b6c317956 | ||
|
b803d877d0 | ||
|
481848a749 | ||
|
25e48f1b9f | ||
|
a402dce293 | ||
|
cf08f54c30 | ||
|
e21801f44e | ||
|
1a9f7e17f8 | ||
|
a8c102f799 | ||
|
925d34daad | ||
|
11f65ff7aa | ||
|
828bc52f80 | ||
|
58a34ae061 | ||
|
a3ddf0ddef | ||
|
837cac300d | ||
|
5be6c403a4 | ||
|
440a143dd6 | ||
|
b9a24a21e7 | ||
|
19ef8b3f7b | ||
|
c2f200fa02 | ||
|
d36d4fb543 | ||
|
5aba9d6196 | ||
|
b081631e90 | ||
|
fb45b67892 | ||
|
2e6d050656 | ||
|
eaba39d99b | ||
|
c4fabc55e8 | ||
|
bf1395801f | ||
|
a7061fe6a5 | ||
|
21de50d84e | ||
|
c78ed91335 | ||
|
01d94176dd | ||
|
601fcb7cf7 | ||
|
8ad3b0d537 | ||
|
472c6424e9 | ||
|
66bc5cd7c2 | ||
|
d064112c16 | ||
|
b3c22dcfd2 | ||
|
5f59d97d21 | ||
|
491999db00 | ||
|
8889cfe993 | ||
|
16d78db72e | ||
|
e866d63ca4 | ||
|
def1a12a4a | ||
|
824729707e | ||
|
0ece2664ac | ||
|
5ef67e8bbf | ||
|
63c70d67c6 | ||
|
432e592e44 | ||
|
182f9e1109 | ||
|
887c4d534a | ||
|
0c6dcfa766 | ||
|
40c83e3cba | ||
|
c57a9dcbc4 | ||
|
58cfaaa527 | ||
|
9f02371d6a | ||
|
df86fd29b3 | ||
|
b4e53431cd | ||
|
b9a81ad382 | ||
|
c4543bce58 | ||
|
cbb5e8cae5 | ||
|
fb1fb4992e | ||
|
dd342f3d30 | ||
|
288a57a4b1 | ||
|
362a43dae7 | ||
|
1dc9bcf798 | ||
|
1ea7902c86 | ||
|
e4d0103544 | ||
|
2078b45ad6 | ||
|
ba1357bcdc | ||
|
9214265136 | ||
|
371eebe1f2 | ||
|
4acd6a16e8 | ||
|
a58ce791c0 | ||
|
160aa191c8 | ||
|
59857a69c0 | ||
|
e5285b0816 | ||
|
621e04e845 | ||
|
75fa27436e | ||
|
cf37439877 | ||
|
9e9c504e2d | ||
|
7cac2d91bc | ||
|
1b962efe41 | ||
|
3a8056f0e2 | ||
|
d77a1a7c60 | ||
|
d2939ac9af | ||
|
78aca869c5 | ||
|
1b664a6b54 | ||
|
7046e2410c | ||
|
0a50962f2b | ||
|
fdbe733df3 | ||
|
1a5478dee9 | ||
|
3c1f6baf24 | ||
|
367d45fcef | ||
|
20f611a075 | ||
|
bee1cbca80 | ||
|
7f6d6dcea1 | ||
|
7e56eb883e | ||
|
97b3b52df5 | ||
|
3ecd6bd422 | ||
|
8ab8e15c4e | ||
|
8fa9a6f3f6 | ||
|
3021e1c944 | ||
|
ecba1eb40b | ||
|
a883fe38aa | ||
|
04fede0897 | ||
|
8b01acefe2 | ||
|
8e7b05f96e | ||
|
458c4cfeeb | ||
|
ea8e08acd2 | ||
|
bf69e09dff | ||
|
714b1c29c9 | ||
|
5ffc3088f3 | ||
|
d661839e32 | ||
|
ccf8aa55f0 | ||
|
bda2941001 | ||
|
33d8a1505c | ||
|
8265ac5a97 | ||
|
1c71010a41 | ||
|
87dabf881a | ||
|
123013c67d | ||
|
e4150e8514 | ||
|
9cbd0138da | ||
|
a4855e1900 | ||
|
3241ea8c00 | ||
|
dc0a5cb8d8 | ||
|
114d1a830b | ||
|
5cf6f1b51e | ||
|
0e8226b5d7 | ||
|
0cfc945a11 | ||
|
dd5a690b99 | ||
|
f119883d67 | ||
|
67363abe4f | ||
|
b6d47eb3b5 | ||
|
639249a395 | ||
|
8d6a694711 | ||
|
422de02ca9 | ||
|
83a2ff17e6 | ||
|
0b3030311c | ||
|
9ae4235c9b | ||
|
464d05c99f | ||
|
dfcf7aafbe | ||
|
5f908a01e4 | ||
|
6bf6ab6479 | ||
|
7ced981d45 | ||
|
bf1af1c0d5 | ||
|
fd367b42da | ||
|
ebfae7e848 | ||
|
cdf39a88eb | ||
|
3fc84ca0ff | ||
|
7f4a568bea | ||
|
6cef65e0d2 | ||
|
79d940aefb | ||
|
eafb5d6096 | ||
|
8a68ea50dc | ||
|
bc56da1d88 | ||
|
521b089501 | ||
|
fbe77c9e0a | ||
|
45a8bd0df1 | ||
|
0e4c66f395 | ||
|
f9cc65c152 | ||
|
08f6e17e91 | ||
|
e56ff22cbb | ||
|
48d12ac62c | ||
|
ef6e1870d9 | ||
|
9bc0f1b82a |
31
.github/workflows/codeql-analysis.yml
vendored
Normal file
31
.github/workflows/codeql-analysis.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master ]
|
||||||
|
schedule:
|
||||||
|
- cron: '45 0 * * 0'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [ 'javascript', 'python' ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v1
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v1
|
56
.github/workflows/django.yml
vendored
Normal file
56
.github/workflows/django.yml
vendored
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
name: Django CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
db: [sqlite, postgres, mysql]
|
||||||
|
python-version: [3.6, 3.7, 3.8, 3.9]
|
||||||
|
include:
|
||||||
|
- db: postgres
|
||||||
|
db_user: runner
|
||||||
|
db_password: ''
|
||||||
|
- db: mysql
|
||||||
|
db_user: root
|
||||||
|
db_password: root
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Start MySQL
|
||||||
|
if: matrix.db == 'mysql'
|
||||||
|
run: sudo systemctl start mysql.service
|
||||||
|
- name: Start PostgreSQL
|
||||||
|
if: matrix.db == 'postgres'
|
||||||
|
run: |
|
||||||
|
sudo systemctl start postgresql.service
|
||||||
|
sudo -u postgres createuser -s runner
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install apprise braintree coverage coveralls mysqlclient
|
||||||
|
- name: Run Tests
|
||||||
|
env:
|
||||||
|
DB: ${{ matrix.db }}
|
||||||
|
DB_USER: ${{ matrix.db_user }}
|
||||||
|
DB_PASSWORD: ${{ matrix.db_password }}
|
||||||
|
SECRET_KEY: dummy-key
|
||||||
|
run: |
|
||||||
|
coverage run --omit=*/tests/* --source=hc manage.py test
|
||||||
|
- name: Coveralls
|
||||||
|
if: matrix.db == 'postgres' && matrix.python-version == '3.8'
|
||||||
|
run: coveralls --service=github
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
35
.github/workflows/publish_docker_image.yml
vendored
Normal file
35
.github/workflows/publish_docker_image.yml
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
name: Publish Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
push_to_registry:
|
||||||
|
name: Push Docker image to Docker Hub
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v3
|
||||||
|
with:
|
||||||
|
images: healthchecks/healthchecks
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.coverage
|
.coverage
|
||||||
|
.env
|
||||||
hc.sqlite
|
hc.sqlite
|
||||||
hc/local_settings.py
|
hc/local_settings.py
|
||||||
static-collected
|
static-collected
|
18
.travis.yml
18
.travis.yml
@ -1,18 +0,0 @@
|
|||||||
language: python
|
|
||||||
python:
|
|
||||||
- "2.7"
|
|
||||||
- "3.5"
|
|
||||||
- "3.6"
|
|
||||||
install:
|
|
||||||
- pip install -r requirements.txt
|
|
||||||
- pip install braintree coveralls mock mysqlclient reportlab
|
|
||||||
env:
|
|
||||||
- DB=sqlite
|
|
||||||
- DB=mysql
|
|
||||||
- DB=postgres
|
|
||||||
addons:
|
|
||||||
postgresql: "9.6"
|
|
||||||
script:
|
|
||||||
- coverage run --omit=*/tests/* --source=hc manage.py test
|
|
||||||
after_success: coveralls
|
|
||||||
cache: pip
|
|
394
CHANGELOG.md
Normal file
394
CHANGELOG.md
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
# Changelog
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## v1.23.0 - Unreleased
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Add handling for non-latin-1 characters in webhook headers
|
||||||
|
- Fix dark mode bug in selectpicker widgets
|
||||||
|
|
||||||
|
## v1.22.0 - 2020-08-06
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Use multicolor channel icons for better appearance in the dark mode
|
||||||
|
- Add SITE_LOGO_URL setting (#323)
|
||||||
|
- Add admin action to log in as any user
|
||||||
|
- Add a "Manager" role (#484)
|
||||||
|
- Add support for 2FA using TOTP (#354)
|
||||||
|
- Add Whitenoise (#548)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fix dark mode styling issues in Cron Syntax Cheatsheet
|
||||||
|
- Fix a 403 when transferring a project to a read-only team member
|
||||||
|
- Security: fix allow_redirect function to reject absolute URLs
|
||||||
|
|
||||||
|
## v1.21.0 - 2020-07-02
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Increase "Success / Failure Keywords" field lengths to 200
|
||||||
|
- Django 3.2.4
|
||||||
|
- Improve the handling of unknown email addresses in the Sign In form
|
||||||
|
- Add support for "... is UP" SMS notifications
|
||||||
|
- Add an option for weekly reports (in addition to monthly)
|
||||||
|
- Implement PagerDuty Simple Install Flow, remove PD Connect
|
||||||
|
- Implement dark mode
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fix off-by-one-month error in monthly reports, downtime columns (#539)
|
||||||
|
|
||||||
|
## v1.20.0 - 2020-04-22
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Django 3.2
|
||||||
|
- Rename VictorOps -> Splunk On-Call
|
||||||
|
- Implement email body decoding in the "Ping Details" dialog
|
||||||
|
- Add a "Subject" field in the "Ping Details" dialog
|
||||||
|
- Improve HTML email display in the "Ping Details" dialog
|
||||||
|
- Add a link to check's details page in Slack notifications
|
||||||
|
- Replace details_url with cloaked_url in email and chat notifications
|
||||||
|
- In the "My Projects" page, show projects with failing checks first
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fix downtime summary to handle months when the check didn't exist yet (#472)
|
||||||
|
- Relax cron expression validation: accept all expressions that croniter accepts
|
||||||
|
- Fix sendalerts to clear Profile.next_nag_date if all checks up
|
||||||
|
- Fix the pause action to clear Profile.next_nag_date if all checks up
|
||||||
|
- Fix the "Email Reports" screen to clear Profile.next_nag_date if all checks up
|
||||||
|
- Fix the month boundary calculation in monthly reports (#497)
|
||||||
|
|
||||||
|
## v1.19.0 - 2021-02-03
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Add tighter parameter checks in hc.front.views.serve_doc
|
||||||
|
- Update OpsGenie instructions (#450)
|
||||||
|
- Update the email notification template to include more check and last ping details
|
||||||
|
- Improve the crontab snippet in the "Check Details" page (#465)
|
||||||
|
- Add Signal integration (#428)
|
||||||
|
- Change Zulip onboarding, ask for the zuliprc file (#202)
|
||||||
|
- Add a section in Docs about running self-hosted instances
|
||||||
|
- Add experimental Dockerfile and docker-compose.yml
|
||||||
|
- Add rate limiting for Pushover notifications (6 notifications / user / minute)
|
||||||
|
- Add support for disabling specific integration types (#471)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fix unwanted HTML escaping in SMS and WhatsApp notifications
|
||||||
|
- Fix a crash when adding an integration for an empty Trello account
|
||||||
|
- Change icon CSS class prefix to 'ic-' to work around Fanboy's filter list
|
||||||
|
|
||||||
|
## v1.18.0 - 2020-12-09
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Add a tooltip to the 'confirmation link' label (#436)
|
||||||
|
- Update API to allow specifying channels by names (#440)
|
||||||
|
- When saving a phone number, remove any invisible unicode characers
|
||||||
|
- Update the read-only dashboard's CSS for better mobile support (#442)
|
||||||
|
- Reduce the number of SQL queries used in the "Get Checks" API call
|
||||||
|
- Add support for script's exit status in ping URLs (#429)
|
||||||
|
- Improve phone number sanitization: remove spaces and hyphens
|
||||||
|
- Change the "Test Integration" behavior for webhooks: don't retry failed requests
|
||||||
|
- Add retries to the the email sending logic
|
||||||
|
- Require confirmation codes (sent to email) before sensitive actions
|
||||||
|
- Implement WebAuthn two-factor authentication
|
||||||
|
- Implement badge mode (up/down vs up/late/down) selector (#282)
|
||||||
|
- Add Ping.exitstatus field, store client's reported exit status values (#455)
|
||||||
|
- Implement header-based authentication (#457)
|
||||||
|
- Add a "Lost password?" link with instructions in the Sign In page
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fix db field overflow when copying a check with a long name
|
||||||
|
|
||||||
|
## v1.17.0 - 2020-10-14
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Django 3.1
|
||||||
|
- Handle status callbacks from Twilio, show delivery failures in Integrations
|
||||||
|
- Removing unused /api/v1/notifications/{uuid}/bounce endpoint
|
||||||
|
- Less verbose output in the `senddeletionnotices` command
|
||||||
|
- Host a read-only dashboard (from github.com/healthchecks/dashboard/)
|
||||||
|
- LINE Notify integration (#412)
|
||||||
|
- Read-only team members
|
||||||
|
- API support for setting the allowed HTTP methods for making ping requests
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Handle excessively long email addresses in the signup form
|
||||||
|
- Handle excessively long email addresses in the team member invite form
|
||||||
|
- Don't allow duplicate team memberships
|
||||||
|
- When copying a check, copy all fields from the "Filtering Rules" dialog (#417)
|
||||||
|
- Fix missing Resume button (#421)
|
||||||
|
- When decoding inbound emails, decode encoded headers (#420)
|
||||||
|
- Escape markdown in MS Teams notifications (#426)
|
||||||
|
- Set the "title" and "summary" fields in MS Teams notifications (#435)
|
||||||
|
|
||||||
|
## v1.16.0 - 2020-08-04
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Paused ping handling can be controlled via API (#376)
|
||||||
|
- Add "Get a list of checks's logged pings" API call (#371)
|
||||||
|
- The /api/v1/checks/ endpoint now accepts either UUID or `unique_key` (#370)
|
||||||
|
- Added /api/v1/checks/uuid/flips/ endpoint (#349)
|
||||||
|
- In the cron expression dialog, show a human-friendly version of the expression
|
||||||
|
- Indicate a started check with a progress spinner under status icon (#338)
|
||||||
|
- Added "Docs > Reliability Tips" page
|
||||||
|
- Spike.sh integration (#402)
|
||||||
|
- Updated Discord integration to use discord.com instead of discordapp.com
|
||||||
|
- Add "Failure Keyword" filtering for inbound emails (#396)
|
||||||
|
- Add support for multiple, comma-separated keywords (#396)
|
||||||
|
- New integration: phone calls (#403)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Removing Pager Team integration, project appears to be discontinued
|
||||||
|
- Sending a test notification updates Channel.last_error (#391)
|
||||||
|
- Handle HTTP 429 responses from Matrix server when joining a Matrix room
|
||||||
|
|
||||||
|
## v1.15.0 - 2020-06-04
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Rate limiting for Telegram notifications (10 notifications per chat per minute)
|
||||||
|
- Use Slack V2 OAuth flow
|
||||||
|
- Users can edit their existing webhook integrations (#176)
|
||||||
|
- Add a "Transfer Ownership" feature in Project Settings
|
||||||
|
- In checks list, the pause button asks for confirmation (#356)
|
||||||
|
- Added /api/v1/metrics/ endpoint, useful for monitoring the service itself
|
||||||
|
- Added "When paused, ignore pings" option in the Filtering Rules dialog (#369)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- "Get a single check" API call now supports read-only API keys (#346)
|
||||||
|
- Don't escape HTML in the subject line of notification emails
|
||||||
|
- Don't let users clone checks if the account is at check limit
|
||||||
|
|
||||||
|
## v1.14.0 - 2020-03-23
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Improved UI to invite users from account's other projects (#258)
|
||||||
|
- Experimental Prometheus metrics endpoint (#300)
|
||||||
|
- Don't store user's current project in DB, put it explicitly in page URLs (#336)
|
||||||
|
- API reference in Markdown
|
||||||
|
- Use Selectize.js for entering tags (#324)
|
||||||
|
- Zulip integration (#202)
|
||||||
|
- OpsGenie integration returns more detailed error messages
|
||||||
|
- Telegram integration returns more detailed error messages
|
||||||
|
- Added the "Get a single check" API call (#337)
|
||||||
|
- Display project name in Slack notifications (#342)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- The "render_docs" command checks if markdown and pygments is installed (#329)
|
||||||
|
- The team size limit is applied to the n. of distinct users across all projects (#332)
|
||||||
|
- API: don't let SuspiciousOperation bubble up when validating channel ids
|
||||||
|
- API security: check channel ownership when setting check's channels
|
||||||
|
- API: update check's "alert_after" field when changing schedule
|
||||||
|
- API: validate channel identifiers before creating/updating a check (#335)
|
||||||
|
- Fix redirect after login when adding Telegram integration
|
||||||
|
|
||||||
|
## v1.13.0 - 2020-02-13
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Show a red "!" in project's top navigation if any integration is not working
|
||||||
|
- createsuperuser management command requires an unique email address (#318)
|
||||||
|
- For superusers, show "Site Administration" in top navigation, note in README (#317)
|
||||||
|
- Make Ping.body size limit configurable (#301)
|
||||||
|
- Show sub-second durations with higher precision, 2 digits after decimal point (#321)
|
||||||
|
- Replace the gear icon with three horizontal dots icon (#322)
|
||||||
|
- Add a Pause button in the checks list (#312)
|
||||||
|
- Documentation in Markdown
|
||||||
|
- Added an example of capturing and submitting log output (#315)
|
||||||
|
- The sendalerts commands measures dwell time and reports it over statsd protocol
|
||||||
|
- Django 3.0.3
|
||||||
|
- Show a warning in top navigation if the project has no integrations (#327)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Increase the allowable length of Matrix room alias to 100 (#320)
|
||||||
|
- Make sure Check.last_ping and Ping.created timestamps match exactly
|
||||||
|
- Don't trigger "down" notifications when changing schedule interactively in web UI
|
||||||
|
- Fix sendalerts crash loop when encountering a bad cron schedule
|
||||||
|
- Stricter cron validation, reject schedules like "At midnight of February 31"
|
||||||
|
- In hc.front.views.ping_details, if a ping does not exist, return a friendly message
|
||||||
|
|
||||||
|
## v1.12.0 - 2020-01-02
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Django 3.0
|
||||||
|
- "Filtering Rules" dialog, an option to require HTTP POST (#297)
|
||||||
|
- Show Healthchecks version in Django admin header (#306)
|
||||||
|
- Added JSON endpoint for Shields.io (#304)
|
||||||
|
- `senddeletionnotices` command skips profiles with recent last_active_date
|
||||||
|
- The "Update Check" API call can update check's description (#311)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Don't set CSRF cookie on first visit. Signup is exempt from CSRF protection
|
||||||
|
- Fix List-Unsubscribe email header value: add angle brackets
|
||||||
|
- Unsubscribe links serve a form, and require HTTP POST to actually unsubscribe
|
||||||
|
- For webhook integration, validate each header line separately
|
||||||
|
- Fix "Send Test Notification" for webhooks that only fire on checks going up
|
||||||
|
- Don't allow adding webhook integrations with both URLs blank
|
||||||
|
- Don't allow adding email integrations with both "up" and "down" unchecked
|
||||||
|
|
||||||
|
|
||||||
|
## v1.11.0 - 2019-11-22
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- In monthly reports, no downtime stats for the current month (month has just started)
|
||||||
|
- Add Microsoft Teams integration (#135)
|
||||||
|
- Add Profile.last_active_date field for more accurate inactive user detection
|
||||||
|
- Add "Shell Commands" integration (#302)
|
||||||
|
- PagerDuty integration works with or without PD_VENDOR_KEY (#303)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- On mobile, "My Checks" page, always show the gear (Details) button (#286)
|
||||||
|
- Make log events fit better on mobile screens
|
||||||
|
|
||||||
|
|
||||||
|
## v1.10.0 - 2019-10-21
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Add the "Last Duration" field in the "My Checks" page (#257)
|
||||||
|
- Add "last_duration" attribute to the Check API resource (#257)
|
||||||
|
- Upgrade to psycopg2 2.8.3
|
||||||
|
- Add Go usage example
|
||||||
|
- Send monthly reports on 1st of every month, not randomly during the month
|
||||||
|
- Signup form sets the "auto-login" cookie to avoid an extra click during first login
|
||||||
|
- Autofocus the email field in the signup form, and submit on enter key
|
||||||
|
- Add support for OpsGenie EU region (#294)
|
||||||
|
- Update OpsGenie logo and setup illustrations
|
||||||
|
- Add a "Create a Copy" function for cloning checks (#288)
|
||||||
|
- Send email notification when monthly SMS sending limit is reached (#292)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Prevent double-clicking the submit button in signup form
|
||||||
|
- Upgrade to Django 2.2.6 – fixes sqlite migrations (#284)
|
||||||
|
|
||||||
|
|
||||||
|
## v1.9.0 - 2019-09-03
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Show the number of downtimes and total downtime minutes in monthly reports (#104)
|
||||||
|
- Show the number of downtimes and total downtime minutes in "Check Details" page
|
||||||
|
- Add the `pruneflips` management command
|
||||||
|
- Add Mattermost integration (#276)
|
||||||
|
- Three choices in timezone switcher (UTC / check's timezone / browser's timezone) (#278)
|
||||||
|
- After adding a new check redirect to the "Check Details" page
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fix javascript code to construct correct URLs when running from a subdirectory (#273)
|
||||||
|
- Don't show the "Sign Up" link in the login page if registration is closed (#280)
|
||||||
|
|
||||||
|
## v1.8.0 - 2019-07-08
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Add the `prunetokenbucket` management command
|
||||||
|
- Show check counts in JSON "badges" (#251)
|
||||||
|
- Webhooks support HTTP PUT (#249)
|
||||||
|
- Webhooks can use different req. bodies and headers for "up" and "down" events (#249)
|
||||||
|
- Show check's code instead of full URL on 992px - 1200px wide screens (#253)
|
||||||
|
- Add WhatsApp integration (uses Twilio same as the SMS integration)
|
||||||
|
- Webhooks support the $TAGS placeholder
|
||||||
|
- Don't include ping URLs in API responses when the read-only key is used
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fix badges for tags containing special characters (#240, #237)
|
||||||
|
- Fix the "Integrations" page for when the user has no active project
|
||||||
|
- Prevent email clients from opening the one-time login links (#255)
|
||||||
|
- Fix `prunepings` and `prunepingsslow`, they got broken when adding Projects (#264)
|
||||||
|
|
||||||
|
|
||||||
|
## v1.7.0 - 2019-05-02
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Add the EMAIL_USE_VERIFICATION configuration setting (#232)
|
||||||
|
- Show "Badges" and "Settings" in top navigation (#234)
|
||||||
|
- Upgrade to Django 2.2
|
||||||
|
- Can configure the email integration to only report the "down" events (#231)
|
||||||
|
- Add "Test!" function in the Integrations page (#207)
|
||||||
|
- Rate limiting for the log in attempts
|
||||||
|
- Password strength meter and length check in the "Set Password" form
|
||||||
|
- Show the Description section even if the description is missing. (#246)
|
||||||
|
- Include the description in email alerts. (#247)
|
||||||
|
|
||||||
|
|
||||||
|
## v1.6.0 - 2019-04-01
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Add the "desc" field (check's description) to API responses
|
||||||
|
- Add maxlength attribute to HTML input=text elements
|
||||||
|
- Improved logic for displaying job execution times in log (#219)
|
||||||
|
- Add Matrix integration
|
||||||
|
- Add Pager Team integration
|
||||||
|
- Add a management command for sending inactive account notifications
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fix refreshing of the checks page filtered by tags (#221)
|
||||||
|
- Escape asterisks in Slack messages (#223)
|
||||||
|
- Fix a "invalid time format" in front.views.status_single on Windows hosts
|
||||||
|
|
||||||
|
|
||||||
|
## v1.5.0 - 2019-02-04
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Database schema: add uniqueness constraint to Check.code
|
||||||
|
- Database schema: add Ping.kind field. Remove "start" and "fail" fields
|
||||||
|
- Add "Email Settings..." dialog and "Subject Must Contain" setting
|
||||||
|
- Database schema: add the Project model
|
||||||
|
- Move project-specific settings to a new "Project Settings" page
|
||||||
|
- Add a "Transfer to Another Project..." dialog
|
||||||
|
- Add the "My Projects" page
|
||||||
|
|
||||||
|
|
||||||
|
## v1.4.0 - 2018-12-25
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Set Pushover alert priorities for "down" and "up" events separately
|
||||||
|
- Additional python usage examples
|
||||||
|
- Allow simultaneous access to checks from different teams
|
||||||
|
- Add CORS support to API endpoints
|
||||||
|
- Flip model, for tracking status changes of the Check objects
|
||||||
|
- Add `/ping/<code>/start` API endpoint
|
||||||
|
- When using the `/start` endpoint, show elapsed times in ping log
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fix after-login redirects (the "?next=" query parameter)
|
||||||
|
- Update Check.status field when user edits timeout & grace settings
|
||||||
|
- Use timezone-aware datetimes with croniter, avoid ambiguities around DST
|
||||||
|
- Validate and reject cron schedules with six components
|
||||||
|
|
||||||
|
|
||||||
|
## v1.3.0 - 2018-11-21
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Load settings from environment variables
|
||||||
|
- Add "List-Unsubscribe" header to alert and report emails
|
||||||
|
- Don't send monthly reports to inactive accounts (no pings in 6 months)
|
||||||
|
- Add search box in the "My Checks" page
|
||||||
|
- Add read-only API key support
|
||||||
|
- Remove Profile.bill_to field (obsolete)
|
||||||
|
- Show a warning when running with DEBUG=True
|
||||||
|
- Add "channels" attribute to the Check API resource
|
||||||
|
- Can specify channel codes when updating a check via API
|
||||||
|
- Add a workaround for email agents automatically opening "Unsubscribe" links
|
||||||
|
- Add Channel.name field, users can now name integrations
|
||||||
|
- Add "Get a List of Existing Integrations" API call
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- During DST transition, handle ambiguous dates as pre-transition
|
||||||
|
|
||||||
|
|
||||||
|
## v1.2.0 - 2018-10-20
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Content updates in the "Welcome" page.
|
||||||
|
- Added "Docs > Third-Party Resources" page.
|
||||||
|
- Improved layout and styling in "Login" page.
|
||||||
|
- Separate "Sign Up" and "Log In" forms.
|
||||||
|
- "My Checks" page: support filtering checks by query string parameters.
|
||||||
|
- Added Trello integration
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Timezones were missing in the "Change Schedule" dialog, fixed.
|
||||||
|
- Fix hamburger menu button in "Login" page.
|
||||||
|
|
||||||
|
|
||||||
|
## v1.1.0 - 2018-08-20
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- A new "Check Details" page.
|
||||||
|
- Updated django-compressor, psycopg2, pytz, requests package versions.
|
||||||
|
- C# usage example.
|
||||||
|
- Checks have a "Description" field.
|
23
CONTRIBUTING.md
Normal file
23
CONTRIBUTING.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
I'm open to feature suggestions and happy to review code contributions.
|
||||||
|
If you are planning to contribute something larger than a small, straightforward
|
||||||
|
bugfix, please open an issue so we can discuss it first. Otherwise you are risking a
|
||||||
|
"no" or a "yes, but let's do it differently" to an already implemented feature.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
* Format your Python code with [black](https://black.readthedocs.io/en/stable/).
|
||||||
|
* Prefer simplicity over cleverness.
|
||||||
|
* If you are fixing a bug or adding a feature, add a test. Run tests before
|
||||||
|
submitting pull requests.
|
||||||
|
|
||||||
|
## Adding Documentation
|
||||||
|
|
||||||
|
This project uses the Markdown format for documentation. Use the `render_docs`
|
||||||
|
management command to generate the HTML version of the documentation. To add a new
|
||||||
|
documentation page:
|
||||||
|
|
||||||
|
1. Create the appropriate .md file under `templates/docs`
|
||||||
|
2. Generate the HTML version with `./manage.py render_docs`
|
||||||
|
3. Add the page to the navigation in `/templates/front/base_docs.html`
|
2
LICENSE
2
LICENSE
@ -1,4 +1,4 @@
|
|||||||
Copyright (c) 2015, Pēteris Caune
|
Copyright (c) 2015, Pēteris Caune and other contributors
|
||||||
All rights reserved.
|
All rights reserved.
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||||
|
399
README.md
399
README.md
@ -1,6 +1,6 @@
|
|||||||
# healthchecks
|
# Healthchecks
|
||||||
|
|
||||||
[](https://travis-ci.org/healthchecks/healthchecks)
|

|
||||||
[](https://coveralls.io/github/healthchecks/healthchecks?branch=master)
|
[](https://coveralls.io/github/healthchecks/healthchecks?branch=master)
|
||||||
|
|
||||||
|
|
||||||
@ -14,161 +14,133 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
healthchecks is a watchdog for your cron jobs. It's a web server that listens for pings from your cron jobs, plus a web interface.
|
Healthchecks is a cron job monitoring service. It listens for HTTP requests
|
||||||
|
and email messages ("pings") from your cron jobs and scheduled tasks ("checks").
|
||||||
|
When a ping does not arrive on time, Healthchecks sends out alerts.
|
||||||
|
|
||||||
It is live here: [http://healthchecks.io/](http://healthchecks.io/)
|
Healthchecks comes with a web dashboard, API, 25+ integrations for
|
||||||
|
delivering notifications, monthly email reports, WebAuthn 2FA support,
|
||||||
|
team management features: projects, team members, read-only access.
|
||||||
|
|
||||||
The building blocks are:
|
The building blocks are:
|
||||||
|
|
||||||
* Python 2 or Python 3
|
* Python 3.6+
|
||||||
* Django 1.11
|
* Django 3
|
||||||
* PostgreSQL or MySQL
|
* PostgreSQL or MySQL
|
||||||
|
|
||||||
|
Healthchecks is licensed under the BSD 3-clause license.
|
||||||
|
|
||||||
|
Healthchecks is available as a hosted service
|
||||||
|
at [https://healthchecks.io/](https://healthchecks.io/).
|
||||||
|
|
||||||
## Setting Up for Development
|
## Setting Up for Development
|
||||||
|
|
||||||
These are instructions for setting up healthchecks Django app
|
To set up Healthchecks development environment:
|
||||||
in development environment.
|
|
||||||
|
|
||||||
* prepare directory for project code and virtualenv:
|
* Install dependencies (Debian/Ubuntu):
|
||||||
|
|
||||||
|
$ sudo apt-get update
|
||||||
|
$ sudo apt-get install -y gcc python3-dev python3-venv libpq-dev
|
||||||
|
|
||||||
|
* Prepare directory for project code and virtualenv. Feel free to use a
|
||||||
|
different location:
|
||||||
|
|
||||||
$ mkdir -p ~/webapps
|
$ mkdir -p ~/webapps
|
||||||
$ cd ~/webapps
|
$ cd ~/webapps
|
||||||
|
|
||||||
* prepare virtual environment
|
* Prepare virtual environment
|
||||||
(with virtualenv you get pip, we'll use it soon to install requirements):
|
(with virtualenv you get pip, we'll use it soon to install requirements):
|
||||||
|
|
||||||
$ virtualenv --python=python3 hc-venv
|
$ python3 -m venv hc-venv
|
||||||
$ source hc-venv/bin/activate
|
$ source hc-venv/bin/activate
|
||||||
|
$ pip3 install wheel # make sure wheel is installed in the venv
|
||||||
|
|
||||||
* check out project code:
|
* Check out project code:
|
||||||
|
|
||||||
$ git clone https://github.com/healthchecks/healthchecks.git
|
$ git clone https://github.com/healthchecks/healthchecks.git
|
||||||
|
|
||||||
* install requirements (Django, ...) into virtualenv:
|
* Install requirements (Django, ...) into virtualenv:
|
||||||
|
|
||||||
$ pip install -r healthchecks/requirements.txt
|
$ pip install -r healthchecks/requirements.txt
|
||||||
|
|
||||||
* healthchecks is configured to use a SQLite database by default. To use
|
|
||||||
PostgreSQL or MySQL database, create and edit `hc/local_settings.py` file.
|
|
||||||
There is a template you can copy and edit as needed:
|
|
||||||
|
|
||||||
$ cd ~/webapps/healthchecks
|
* Create database tables and a superuser account:
|
||||||
$ cp hc/local_settings.py.example hc/local_settings.py
|
|
||||||
|
|
||||||
* create database tables and the superuser account:
|
|
||||||
|
|
||||||
$ cd ~/webapps/healthchecks
|
$ cd ~/webapps/healthchecks
|
||||||
$ ./manage.py migrate
|
$ ./manage.py migrate
|
||||||
$ ./manage.py createsuperuser
|
$ ./manage.py createsuperuser
|
||||||
|
|
||||||
* run development server:
|
With the default configuration, Healthchecks stores data in a SQLite file
|
||||||
|
`hc.sqlite` in the checkout directory (`~/webapps/healthchecks`).
|
||||||
|
|
||||||
|
To use PostgreSQL or MySQL, see the section **Database Configuration** section
|
||||||
|
below.
|
||||||
|
|
||||||
|
* Run tests:
|
||||||
|
|
||||||
|
$ ./manage.py test
|
||||||
|
|
||||||
|
* Run development server:
|
||||||
|
|
||||||
$ ./manage.py runserver
|
$ ./manage.py runserver
|
||||||
|
|
||||||
The site should now be running at `http://localhost:8080`
|
The site should now be running at `http://localhost:8000`.
|
||||||
To log into Django administration site as a super user,
|
To access Django administration site, log in as a superuser, then
|
||||||
visit `http://localhost:8080/admin`
|
visit `http://localhost:8000/admin/`
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Site configuration is kept in `hc/settings.py`. Additional configuration
|
Healthchecks reads configuration from environment variables.
|
||||||
is loaded from `hc/local_settings.py` file, if it exists. You
|
|
||||||
can create this file (should be right next to `settings.py` in the filesystem)
|
|
||||||
and override settings as needed.
|
|
||||||
|
|
||||||
Some useful settings keys to override are:
|
[Full list of configuration parameters](https://healthchecks.io/docs/self_hosted_configuration/).
|
||||||
|
|
||||||
`SITE_ROOT` is used to build fully qualified URLs for pings, and for use in
|
## Accessing Administration Panel
|
||||||
emails and notifications. Example:
|
|
||||||
|
|
||||||
```python
|
Healthchecks comes with Django's administration panel where you can manually
|
||||||
SITE_ROOT = "https://my-monitoring-project.com"
|
view and modify user accounts, projects, checks, integrations etc. To access it,
|
||||||
```
|
|
||||||
|
|
||||||
`SITE_NAME` has the default value of "Mychecks" and is used throughout
|
* if you haven't already, create a superuser account: `./manage.py createsuperuser`
|
||||||
the templates. Replace it with your own name to personalize your installation.
|
* log into the site using superuser credentials
|
||||||
Example:
|
* in the top navigation, "Account" dropdown, select "Site Administration"
|
||||||
|
|
||||||
```python
|
|
||||||
SITE_NAME = "My Monitoring Project"
|
|
||||||
```
|
|
||||||
|
|
||||||
`REGISTRATION_OPEN` controls whether site visitors can create new accounts.
|
|
||||||
Set it to `False` if you are setting up a private healthchecks instance, but
|
|
||||||
it needs to be publicly accessible (so, for example, your cloud services
|
|
||||||
can send pings).
|
|
||||||
|
|
||||||
If you close new user registration, you can still selectively invite users
|
|
||||||
to your team account.
|
|
||||||
|
|
||||||
|
|
||||||
## Database Configuration
|
|
||||||
|
|
||||||
Database configuration is stored in `hc/settings.py` and can be overriden
|
|
||||||
in `hc/local_settings.py`. The default database engine is SQLite. To use
|
|
||||||
PostgreSQL, create `hc/local_settings.py` if it does not exist, and put the
|
|
||||||
following in it, changing it as neccessary:
|
|
||||||
|
|
||||||
```python
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django.db.backends.postgresql',
|
|
||||||
'NAME': 'your-database-name-here',
|
|
||||||
'USER': 'your-database-user-here',
|
|
||||||
'PASSWORD': 'your-database-password-here',
|
|
||||||
'TEST': {'CHARSET': 'UTF8'}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
For MySQL:
|
|
||||||
|
|
||||||
```python
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django.db.backends.mysql',
|
|
||||||
'NAME': 'your-database-name-here',
|
|
||||||
'USER': 'your-database-user-here',
|
|
||||||
'PASSWORD': 'your-database-password-here',
|
|
||||||
'TEST': {'CHARSET': 'UTF8'}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also use `hc/local_settings.py` to read database
|
|
||||||
configuration from environment variables like so:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import os
|
|
||||||
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': os.environ['DB_ENGINE'],
|
|
||||||
'NAME': os.environ['DB_NAME'],
|
|
||||||
'USER': os.environ['DB_USER'],
|
|
||||||
'PASSWORD': os.environ['DB_PASSWORD'],
|
|
||||||
'TEST': {'CHARSET': 'UTF8'}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Sending Emails
|
## Sending Emails
|
||||||
|
|
||||||
healthchecks must be able to send email messages, so it can send out login
|
Healthchecks must be able to send email messages, so it can send out login
|
||||||
links and alerts to users. Put your SMTP server configuration in
|
links and alerts to users. Specify your SMTP credentials using the following
|
||||||
`hc/local_settings.py` like so:
|
environment variables:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
EMAIL_HOST = "your-smtp-server-here.com"
|
EMAIL_HOST = "your-smtp-server-here.com"
|
||||||
EMAIL_PORT = 587
|
EMAIL_PORT = 587
|
||||||
EMAIL_HOST_USER = "username"
|
EMAIL_HOST_USER = "smtp-username"
|
||||||
EMAIL_HOST_PASSWORD = "password"
|
EMAIL_HOST_PASSWORD = "smtp-password"
|
||||||
EMAIL_USE_TLS = True
|
EMAIL_USE_TLS = True
|
||||||
```
|
```
|
||||||
|
|
||||||
For more information, have a look at Django documentation,
|
For more information, have a look at Django documentation,
|
||||||
[Sending Email](https://docs.djangoproject.com/en/1.10/topics/email/) section.
|
[Sending Email](https://docs.djangoproject.com/en/1.10/topics/email/) section.
|
||||||
|
|
||||||
|
## Receiving Emails
|
||||||
|
|
||||||
|
Healthchecks comes with a `smtpd` management command, which starts up a
|
||||||
|
SMTP listener service. With the command running, you can ping your
|
||||||
|
checks by sending email messages
|
||||||
|
to `your-uuid-here@my-monitoring-project.com` email addresses.
|
||||||
|
|
||||||
|
Start the SMTP listener on port 2525:
|
||||||
|
|
||||||
|
$ ./manage.py smtpd --port 2525
|
||||||
|
|
||||||
|
Send a test email:
|
||||||
|
|
||||||
|
$ curl --url 'smtp://127.0.0.1:2525' \
|
||||||
|
--mail-from 'foo@example.org' \
|
||||||
|
--mail-rcpt '11111111-1111-1111-1111-111111111111@my-monitoring-project.com' \
|
||||||
|
-F '='
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Sending Status Notifications
|
## Sending Status Notifications
|
||||||
|
|
||||||
healtchecks comes with a `sendalerts` management command, which continuously
|
healtchecks comes with a `sendalerts` management command, which continuously
|
||||||
@ -183,7 +155,7 @@ manager like [supervisor](http://supervisord.org/) or systemd.
|
|||||||
|
|
||||||
## Database Cleanup
|
## Database Cleanup
|
||||||
|
|
||||||
With time and use the healthchecks database will grow in size. You may
|
With time and use the Healthchecks database will grow in size. You may
|
||||||
decide to prune old data: inactive user accounts, old checks not assigned
|
decide to prune old data: inactive user accounts, old checks not assigned
|
||||||
to users, records of outgoing email messages and records of received pings.
|
to users, records of outgoing email messages and records of received pings.
|
||||||
There are separate Django management commands for each task:
|
There are separate Django management commands for each task:
|
||||||
@ -195,13 +167,10 @@ There are separate Django management commands for each task:
|
|||||||
$ ./manage.py prunepings
|
$ ./manage.py prunepings
|
||||||
```
|
```
|
||||||
|
|
||||||
* Remove checks older than 2 hours that are not assigned to users. Such
|
Note: 100 is the default value but you can configure a different
|
||||||
checks are by-products of random visitors and robots loading the welcome
|
limit per-user. To do that, go to the
|
||||||
page and never setting up an account:
|
Administration Panel, look up user's **Profile** and modify its
|
||||||
|
"Ping log limit" field.
|
||||||
```
|
|
||||||
$ ./manage.py prunechecks
|
|
||||||
```
|
|
||||||
|
|
||||||
* Remove old records of sent notifications. For each check, remove
|
* Remove old records of sent notifications. For each check, remove
|
||||||
notifications that are older than the oldest stored ping for same check.
|
notifications that are older than the oldest stored ping for same check.
|
||||||
@ -221,13 +190,80 @@ There are separate Django management commands for each task:
|
|||||||
$ ./manage.py pruneusers
|
$ ./manage.py pruneusers
|
||||||
```
|
```
|
||||||
|
|
||||||
|
* Remove old records from the `api_tokenbucket` table. The TokenBucket
|
||||||
|
model is used for rate-limiting login attempts and similar operations.
|
||||||
|
Any records older than one day can be safely removed.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ./manage.py prunetokenbucket
|
||||||
|
```
|
||||||
|
|
||||||
|
* Remove old records from the `api_flip` table. The Flip
|
||||||
|
objects are used to track status changes of checks, and to calculate
|
||||||
|
downtime statistics month by month. Flip objects from more than 3 months
|
||||||
|
ago are not used and can be safely removed.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ./manage.py pruneflips
|
||||||
|
```
|
||||||
|
|
||||||
When you first try these commands on your data, it is a good idea to
|
When you first try these commands on your data, it is a good idea to
|
||||||
test them on a copy of your database, not on the live database right away.
|
test them on a copy of your database, not on the live database right away.
|
||||||
In a production setup, you should also have regular, automated database
|
In a production setup, you should also have regular, automated database
|
||||||
backups set up.
|
backups set up.
|
||||||
|
|
||||||
|
## Two-factor Authentication
|
||||||
|
|
||||||
|
Healthchecks optionally supports two-factor authentication using the WebAuthn
|
||||||
|
standard. To enable WebAuthn support, set the `RP_ID` (relying party identifier )
|
||||||
|
setting to a non-null value. Set its value to your site's domain without scheme
|
||||||
|
and without port. For example, if your site runs on `https://my-hc.example.org`,
|
||||||
|
set `RP_ID` to `my-hc.example.org`.
|
||||||
|
|
||||||
|
Note that WebAuthn requires HTTPS, even if running on localhost. To test WebAuthn
|
||||||
|
locally with a self-signed certificate, you can use the `runsslserver` command
|
||||||
|
from the `django-sslserver` package.
|
||||||
|
|
||||||
|
## External Authentication
|
||||||
|
|
||||||
|
Healthchecks supports external authentication by means of HTTP headers set by
|
||||||
|
reverse proxies or the WSGI server. This allows you to integrate it into your
|
||||||
|
existing authentication system (e.g., LDAP or OAuth) via an authenticating proxy.
|
||||||
|
When this option is enabled, **healtchecks will trust the header's value implicitly**,
|
||||||
|
so it is **very important** to ensure that attackers cannot set the value themselves
|
||||||
|
(and thus impersonate any user). How to do this varies by your chosen proxy,
|
||||||
|
but generally involves configuring it to strip out headers that normalize to the
|
||||||
|
same name as the chosen identity header.
|
||||||
|
|
||||||
|
To enable this feature, set the `REMOTE_USER_HEADER` value to a header you wish to
|
||||||
|
authenticate with. HTTP headers will be prefixed with `HTTP_` and have any dashes
|
||||||
|
converted to underscores. Headers without that prefix can be set by the WSGI server
|
||||||
|
itself only, which is more secure.
|
||||||
|
|
||||||
|
When `REMOTE_USER_HEADER` is set, Healthchecks will:
|
||||||
|
- assume the header contains user's email address
|
||||||
|
- look up and automatically log in the user with a matching email address
|
||||||
|
- automatically create an user account if it does not exist
|
||||||
|
- disable the default authentication methods (login link to email, password)
|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
|
### Slack
|
||||||
|
|
||||||
|
To enable the Slack "self-service" integration, you will need to create a "Slack App".
|
||||||
|
|
||||||
|
To do so:
|
||||||
|
* Create a _new Slack app_ on https://api.slack.com/apps/
|
||||||
|
* Add at least _one scope_ in the permissions section to be able to deploy the app in your workspace (By example `incoming-webhook` for the `Bot Token Scopes`
|
||||||
|
https://api.slack.com/apps/APP_ID/oauth?).
|
||||||
|
* Add a _redirect url_ in the format `SITE_ROOT/integrations/add_slack_btn/`.
|
||||||
|
For example, if your SITE_ROOT is `https://my-hc.example.org` then the redirect URL would be
|
||||||
|
`https://my-hc.example.org/integrations/add_slack_btn/`.
|
||||||
|
* Look up your Slack app for the Client ID and Client Secret at https://api.slack.com/apps/APP_ID/general? . Put them
|
||||||
|
in `SLACK_CLIENT_ID` and `SLACK_CLIENT_SECRET` environment
|
||||||
|
variables.
|
||||||
|
|
||||||
|
|
||||||
### Discord
|
### Discord
|
||||||
|
|
||||||
To enable Discord integration, you will need to:
|
To enable Discord integration, you will need to:
|
||||||
@ -237,29 +273,55 @@ To enable Discord integration, you will need to:
|
|||||||
`SITE_ROOT/integrations/add_discord/`. For example, if you are running a
|
`SITE_ROOT/integrations/add_discord/`. For example, if you are running a
|
||||||
development server on `localhost:8000` then the redirect URI would be
|
development server on `localhost:8000` then the redirect URI would be
|
||||||
`http://localhost:8000/integrations/add_discord/`
|
`http://localhost:8000/integrations/add_discord/`
|
||||||
* Look up your Discord app's Client ID and Client Secret. Add them
|
* Look up your Discord app's Client ID and Client Secret. Put them
|
||||||
to your `hc/local_settings.py` file as `DISCORD_CLIENT_ID` and
|
in `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET` environment
|
||||||
`DISCORD_CLIENT_SECRET` fields.
|
variables.
|
||||||
|
|
||||||
|
|
||||||
### Pushover
|
### Pushover
|
||||||
|
|
||||||
To enable Pushover integration, you will need to:
|
Pushover integration works by creating an application on Pushover.net which
|
||||||
|
is then subscribed to by Healthchecks users. The registration workflow is as follows:
|
||||||
|
|
||||||
|
* On Healthchecks, the user adds a "Pushover" integration to a project
|
||||||
|
* Healthchecks redirects user's browser to a Pushover.net subscription page
|
||||||
|
* User approves adding the Healthchecks subscription to their Pushover account
|
||||||
|
* Pushover.net HTTP redirects back to Healthchecks with a subscription token
|
||||||
|
* Healthchecks saves the subscription token and uses it for sending Pushover
|
||||||
|
notifications
|
||||||
|
|
||||||
|
To enable the Pushover integration, you will need to:
|
||||||
|
|
||||||
|
* Register a new application on Pushover via https://pushover.net/apps/build.
|
||||||
|
* Within the Pushover 'application' configuration, enable subscriptions.
|
||||||
|
Make sure the subscription type is set to "URL". Also make sure the redirect
|
||||||
|
URL is configured to point back to the root of the Healthchecks instance
|
||||||
|
(e.g., `http://healthchecks.example.com/`).
|
||||||
|
* Put the Pushover application API Token and the Pushover subscription URL in
|
||||||
|
`PUSHOVER_API_TOKEN` and `PUSHOVER_SUBSCRIPTION_URL` environment
|
||||||
|
variables. The Pushover subscription URL should look similar to
|
||||||
|
`https://pushover.net/subscribe/yourAppName-randomAlphaNumericData`.
|
||||||
|
|
||||||
|
### Signal
|
||||||
|
|
||||||
|
Healthchecks uses [signal-cli](https://github.com/AsamK/signal-cli) to send Signal
|
||||||
|
notifications. Healthcecks interacts with signal-cli over DBus.
|
||||||
|
|
||||||
|
To enable the Signal integration:
|
||||||
|
|
||||||
|
* Set up and configure signal-cli to listen on DBus system bus ([instructions](https://github.com/AsamK/signal-cli/wiki/DBus-service)).
|
||||||
|
Make sure you can send test messages from command line, using the `dbus-send`
|
||||||
|
example given in the signal-cli instructions.
|
||||||
|
* Set the `SIGNAL_CLI_ENABLED` environment variable to `True`.
|
||||||
|
|
||||||
* register a new application on https://pushover.net/apps/build
|
|
||||||
* enable subscriptions in your application and make sure to enable the URL
|
|
||||||
subscription type
|
|
||||||
* add the application token and subscription URL to `hc/local_settings.py`, as
|
|
||||||
`PUSHOVER_API_TOKEN` and `PUSHOVER_SUBSCRIPTION_URL`
|
|
||||||
|
|
||||||
### Telegram
|
### Telegram
|
||||||
|
|
||||||
* Create a Telegram bot by talking to the
|
* Create a Telegram bot by talking to the
|
||||||
[BotFather](https://core.telegram.org/bots#6-botfather). Set the bot's name,
|
[BotFather](https://core.telegram.org/bots#6-botfather). Set the bot's name,
|
||||||
description, user picture, and add a "/start" command.
|
description, user picture, and add a "/start" command.
|
||||||
* After creating the bot you will have the bot's name and token. Add them
|
* After creating the bot you will have the bot's name and token. Put them
|
||||||
to your `hc/local_settings.py` file as `TELEGRAM_BOT_NAME` and
|
in `TELEGRAM_BOT_NAME` and `TELEGRAM_TOKEN` environment variables.
|
||||||
`TELEGRAM_TOKEN` fields.
|
|
||||||
* Run `settelegramwebhook` management command. This command tells Telegram
|
* Run `settelegramwebhook` management command. This command tells Telegram
|
||||||
where to forward channel messages by invoking Telegram's
|
where to forward channel messages by invoking Telegram's
|
||||||
[setWebhook](https://core.telegram.org/bots/api#setwebhook) API call:
|
[setWebhook](https://core.telegram.org/bots/api#setwebhook) API call:
|
||||||
@ -271,3 +333,92 @@ where to forward channel messages by invoking Telegram's
|
|||||||
|
|
||||||
For this to work, your `SITE_ROOT` needs to be correct and use "https://"
|
For this to work, your `SITE_ROOT` needs to be correct and use "https://"
|
||||||
scheme.
|
scheme.
|
||||||
|
|
||||||
|
### Apprise
|
||||||
|
|
||||||
|
To enable Apprise integration, you will need to:
|
||||||
|
|
||||||
|
* ensure you have apprise installed in your local environment:
|
||||||
|
```bash
|
||||||
|
pip install apprise
|
||||||
|
```
|
||||||
|
* enable the apprise functionality by setting the `APPRISE_ENABLED` environment variable.
|
||||||
|
|
||||||
|
### Shell Commands
|
||||||
|
|
||||||
|
The "Shell Commands" integration runs user-defined local shell commands when checks
|
||||||
|
go up or down. This integration is disabled by default, and can be enabled by setting
|
||||||
|
the `SHELL_ENABLED` environment variable to `True`.
|
||||||
|
|
||||||
|
Note: be careful when using "Shell Commands" integration, and only enable it when
|
||||||
|
you fully trust the users of your Healthchecks instance. The commands will be executed
|
||||||
|
by the `manage.py sendalerts` process, and will run with the same system permissions as
|
||||||
|
the `sendalerts` process.
|
||||||
|
|
||||||
|
### Matrix
|
||||||
|
|
||||||
|
To enable the Matrix integration you will need to:
|
||||||
|
|
||||||
|
* Register a bot user (for posting notifications) in your preferred homeserver.
|
||||||
|
* Use the [Login API call](https://www.matrix.org/docs/guides/client-server-api#login)
|
||||||
|
to retrieve bot user's access token. You can run it as shown in the documentation,
|
||||||
|
using curl in command shell.
|
||||||
|
* Set the `MATRIX_` environment variables. Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
MATRIX_HOMESERVER=https://matrix.org
|
||||||
|
MATRIX_USER_ID=@mychecks:matrix.org
|
||||||
|
MATRIX_ACCESS_TOKEN=[a long string of characters returned by the login call]
|
||||||
|
```
|
||||||
|
|
||||||
|
### PagerDuty Simple Install Flow
|
||||||
|
|
||||||
|
To enable PagerDuty [Simple Install Flow](https://developer.pagerduty.com/docs/app-integration-development/events-integration/),
|
||||||
|
|
||||||
|
* Register a PagerDuty app at [PagerDuty](https://pagerduty.com/) › Developer Mode › My Apps
|
||||||
|
* In the newly created app, add the "Events Integration" functionality
|
||||||
|
* Specify a Redirect URL: `https://your-domain.com/integrations/add_pagerduty/`
|
||||||
|
* Copy the displayed app_id value (PXXXXX) and put it in the `PD_APP_ID` environment
|
||||||
|
variable
|
||||||
|
|
||||||
|
## Running in Production
|
||||||
|
|
||||||
|
Here is a non-exhaustive list of pointers and things to check before launching a Healthchecks instance
|
||||||
|
in production.
|
||||||
|
|
||||||
|
* Environment variables, settings.py and local_settings.py.
|
||||||
|
* [DEBUG](https://docs.djangoproject.com/en/2.2/ref/settings/#debug). Make sure it is
|
||||||
|
set to `False`.
|
||||||
|
* [ALLOWED_HOSTS](https://docs.djangoproject.com/en/2.2/ref/settings/#allowed-hosts).
|
||||||
|
Make sure it contains the correct domain name you want to use.
|
||||||
|
* Server Errors. When DEBUG=False, Django will not show detailed error pages, and
|
||||||
|
will not print exception tracebacks to standard output. To receive exception
|
||||||
|
tracebacks in email, review and edit the
|
||||||
|
[ADMINS](https://docs.djangoproject.com/en/2.2/ref/settings/#admins) and
|
||||||
|
[SERVER_EMAIL](https://docs.djangoproject.com/en/2.2/ref/settings/#server-email)
|
||||||
|
settings. Consider setting up exception logging with [Sentry](https://sentry.io/for/django/).
|
||||||
|
* Management commands that need to be run during each deployment.
|
||||||
|
* `manage.py compress` – creates combined JS and CSS bundles and
|
||||||
|
places them in the `static-collected` directory.
|
||||||
|
* `manage.py collectstatic` – collects static files in the `static-collected`
|
||||||
|
directory.
|
||||||
|
* `manage.py migrate` – applies any pending database schema changes
|
||||||
|
and data migrations.
|
||||||
|
* Processes that need to be running constantly.
|
||||||
|
* `manage.py runserver` is intended for development only.
|
||||||
|
**Do not use it in production**, instead consider using
|
||||||
|
[uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) or
|
||||||
|
[gunicorn](https://gunicorn.org/).
|
||||||
|
* `manage.py sendalerts` is the process that monitors checks and sends out
|
||||||
|
monitoring alerts. It must be always running, it must be started on reboot, and it
|
||||||
|
must be restarted if it itself crashes. On modern linux systems, a good option is
|
||||||
|
to [define a systemd service](https://github.com/healthchecks/healthchecks/issues/273#issuecomment-520560304)
|
||||||
|
for it.
|
||||||
|
* Static files. Healthchecks serves static files on its own, no configuration
|
||||||
|
required. It uses the [Whitenoise library](http://whitenoise.evans.io/en/stable/index.html)
|
||||||
|
for this.
|
||||||
|
* General
|
||||||
|
* Make sure the database is secured well and is getting backed up regularly
|
||||||
|
* Make sure the TLS certificates are secured well and are getting refreshed regularly
|
||||||
|
* Have monitoring in place to be sure the Healthchecks instance itself is operational
|
||||||
|
(is accepting pings, is sending out alerts, is not running out of resources).
|
||||||
|
67
docker/.env
Normal file
67
docker/.env
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
ALLOWED_HOSTS=localhost
|
||||||
|
APPRISE_ENABLED=False
|
||||||
|
DB=postgres
|
||||||
|
DB_CONN_MAX_AGE=0
|
||||||
|
DB_HOST=db
|
||||||
|
DB_NAME=hc
|
||||||
|
DB_PASSWORD=fixme-postgres-password
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_SSLMODE=prefer
|
||||||
|
DB_TARGET_SESSION_ATTRS=read-write
|
||||||
|
DB_USER=postgres
|
||||||
|
DEBUG=False
|
||||||
|
DEFAULT_FROM_EMAIL=healthchecks@example.org
|
||||||
|
DISCORD_CLIENT_ID=
|
||||||
|
DISCORD_CLIENT_SECRET=
|
||||||
|
EMAIL_HOST=
|
||||||
|
EMAIL_HOST_PASSWORD=
|
||||||
|
EMAIL_HOST_USER=
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_USE_TLS=True
|
||||||
|
EMAIL_USE_VERIFICATION=True
|
||||||
|
LINENOTIFY_CLIENT_ID=
|
||||||
|
LINENOTIFY_CLIENT_SECRET=
|
||||||
|
MASTER_BADGE_LABEL=Mychecks
|
||||||
|
MATRIX_ACCESS_TOKEN=
|
||||||
|
MATRIX_HOMESERVER=
|
||||||
|
MATRIX_USER_ID=
|
||||||
|
MATTERMOST_ENABLED=True
|
||||||
|
MSTEAMS_ENABLED=True
|
||||||
|
OPSGENIE_ENABLED=True
|
||||||
|
PAGERTREE_ENABLED=True
|
||||||
|
PD_APP_ID=
|
||||||
|
PD_ENABLED=True
|
||||||
|
PD_VENDOR_KEY=
|
||||||
|
PING_BODY_LIMIT=10000
|
||||||
|
PING_EMAIL_DOMAIN=localhost
|
||||||
|
PING_ENDPOINT=http://localhost:8000/ping/
|
||||||
|
PROMETHEUS_ENABLED=True
|
||||||
|
PUSHBULLET_CLIENT_ID=
|
||||||
|
PUSHBULLET_CLIENT_SECRET=
|
||||||
|
PUSHOVER_API_TOKEN=
|
||||||
|
PUSHOVER_EMERGENCY_EXPIRATION=86400
|
||||||
|
PUSHOVER_EMERGENCY_RETRY_DELAY=300
|
||||||
|
PUSHOVER_SUBSCRIPTION_URL=
|
||||||
|
REGISTRATION_OPEN=True
|
||||||
|
REMOTE_USER_HEADER=
|
||||||
|
RP_ID=
|
||||||
|
SECRET_KEY=---
|
||||||
|
SHELL_ENABLED=False
|
||||||
|
SIGNAL_CLI_ENABLED=False
|
||||||
|
SITE_NAME=Mychecks
|
||||||
|
SITE_ROOT=http://localhost:8000
|
||||||
|
SLACK_CLIENT_ID=
|
||||||
|
SLACK_CLIENT_SECRET=
|
||||||
|
SLACK_ENABLED=True
|
||||||
|
SPIKE_ENABLED=True
|
||||||
|
TELEGRAM_BOT_NAME=ExampleBot
|
||||||
|
TELEGRAM_TOKEN=
|
||||||
|
TRELLO_APP_KEY=
|
||||||
|
TWILIO_ACCOUNT=
|
||||||
|
TWILIO_AUTH=
|
||||||
|
TWILIO_FROM=
|
||||||
|
TWILIO_USE_WHATSAPP=False
|
||||||
|
USE_PAYMENTS=False
|
||||||
|
VICTOROPS_ENABLED=True
|
||||||
|
WEBHOOKS_ENABLED=True
|
||||||
|
ZULIP_ENABLED=True
|
23
docker/Dockerfile
Normal file
23
docker/Dockerfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.9
|
||||||
|
|
||||||
|
RUN useradd --system hc
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1
|
||||||
|
WORKDIR /opt/healthchecks
|
||||||
|
|
||||||
|
COPY requirements.txt /tmp
|
||||||
|
RUN \
|
||||||
|
pip install --no-cache-dir -r /tmp/requirements.txt && \
|
||||||
|
pip install uwsgi
|
||||||
|
|
||||||
|
COPY . /opt/healthchecks/
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
rm -f /opt/healthchecks/hc/local_settings.py && \
|
||||||
|
DEBUG=False SECRET_KEY=build-key ./manage.py collectstatic --noinput && \
|
||||||
|
DEBUG=False SECRET_KEY=build-key ./manage.py compress
|
||||||
|
|
||||||
|
USER hc
|
||||||
|
|
||||||
|
CMD [ "uwsgi", "/opt/healthchecks/docker/uwsgi.ini"]
|
||||||
|
|
32
docker/README.md
Normal file
32
docker/README.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Running with Docker
|
||||||
|
|
||||||
|
This is a sample configuration for running Healthchecks with
|
||||||
|
[Docker](https://www.docker.com) and [Docker Compose](https://docs.docker.com/compose/).
|
||||||
|
|
||||||
|
**Note: The Docker configuration is a recent addition, and, for the time being,
|
||||||
|
should be considered as highly experimental**.
|
||||||
|
|
||||||
|
Note: For the sake of simplicity, the sample configuration starts a single database
|
||||||
|
node and a single web server node, both on the same host. It also does not handle SSL
|
||||||
|
termination. If you plan to expose it to the public internet, make sure you put a
|
||||||
|
SSL-terminating load balancer or reverse proxy in front of it.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
* Add your configuration in the `/docker/.env` file.
|
||||||
|
As a minimum, set the following fields:
|
||||||
|
* `DEFAULT_FROM_EMAIL` – the "From:" address for outbound emails
|
||||||
|
* `EMAIL_HOST` – the SMTP server
|
||||||
|
* `EMAIL_HOST_PASSWORD` – the SMTP password
|
||||||
|
* `EMAIL_HOST_USER` – the SMTP username
|
||||||
|
* `SECRET_KEY` – secures HTTP sessions, set to a random value
|
||||||
|
* Create and start containers:
|
||||||
|
|
||||||
|
$ docker-compose up
|
||||||
|
|
||||||
|
* Create a superuser:
|
||||||
|
|
||||||
|
$ docker-compose run web /opt/healthchecks/manage.py createsuperuser
|
||||||
|
|
||||||
|
* Open [http://localhost:8000](http://localhost:8000) in your browser and log in with
|
||||||
|
the credentials from the previous step.
|
24
docker/docker-compose.yml
Normal file
24
docker/docker-compose.yml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
version: "3"
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:12
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/postgresql
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=$DB_NAME
|
||||||
|
- POSTGRES_PASSWORD=$DB_PASSWORD
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- 8000:8000
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
command: bash -c 'while !</dev/tcp/db/5432; do sleep 1; done; uwsgi /opt/healthchecks/docker/uwsgi.ini'
|
||||||
|
|
17
docker/uwsgi.ini
Normal file
17
docker/uwsgi.ini
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
[uwsgi]
|
||||||
|
master
|
||||||
|
die-on-term
|
||||||
|
http-socket = :8000
|
||||||
|
harakiri = 10
|
||||||
|
post-buffering = 4096
|
||||||
|
processes = 4
|
||||||
|
enable-threads
|
||||||
|
threads = 1
|
||||||
|
chdir = /opt/healthchecks
|
||||||
|
module = hc.wsgi:application
|
||||||
|
thunder-lock
|
||||||
|
disable-write-exception
|
||||||
|
|
||||||
|
hook-pre-app = exec:./manage.py migrate
|
||||||
|
attach-daemon = ./manage.py sendalerts
|
||||||
|
attach-daemon = ./manage.py sendreports --loop
|
@ -1,12 +1,35 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth import login as auth_login
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db.models import Count
|
from django.db.models import Count, F
|
||||||
|
from django.shortcuts import redirect
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from hc.accounts.models import Profile
|
from hc.accounts.models import Credential, Profile, Project
|
||||||
from hc.api.models import Channel, Check
|
|
||||||
|
|
||||||
|
@mark_safe
|
||||||
|
def _format_usage(num_checks, num_channels):
|
||||||
|
result = ""
|
||||||
|
|
||||||
|
if num_checks == 0:
|
||||||
|
result += "0 checks, "
|
||||||
|
elif num_checks == 1:
|
||||||
|
result += "1 check, "
|
||||||
|
else:
|
||||||
|
result += f"<strong>{num_checks} checks</strong>, "
|
||||||
|
|
||||||
|
if num_channels == 0:
|
||||||
|
result += "0 channels"
|
||||||
|
elif num_channels == 1:
|
||||||
|
result += "1 channel"
|
||||||
|
else:
|
||||||
|
result += f"<strong>{num_channels} channels</strong>"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class Fieldset:
|
class Fieldset:
|
||||||
@ -20,110 +43,200 @@ class Fieldset:
|
|||||||
|
|
||||||
class ProfileFieldset(Fieldset):
|
class ProfileFieldset(Fieldset):
|
||||||
name = "User Profile"
|
name = "User Profile"
|
||||||
fields = ("email", "api_key", "current_team", "reports_allowed",
|
fields = (
|
||||||
"next_report_date", "nag_period", "next_nag_date",
|
"email",
|
||||||
"token", "sort")
|
"reports",
|
||||||
|
"tz",
|
||||||
|
"theme",
|
||||||
|
"next_report_date",
|
||||||
|
"nag_period",
|
||||||
|
"next_nag_date",
|
||||||
|
"deletion_notice_date",
|
||||||
|
"token",
|
||||||
|
"sort",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TeamFieldset(Fieldset):
|
class TeamFieldset(Fieldset):
|
||||||
name = "Team"
|
name = "Team"
|
||||||
fields = ("team_name", "team_limit", "check_limit",
|
fields = (
|
||||||
"ping_log_limit", "sms_limit", "sms_sent", "last_sms_date",
|
"team_limit",
|
||||||
"bill_to")
|
"check_limit",
|
||||||
|
"ping_log_limit",
|
||||||
|
"sms_limit",
|
||||||
|
"sms_sent",
|
||||||
|
"last_sms_date",
|
||||||
|
"call_limit",
|
||||||
|
"calls_sent",
|
||||||
|
"last_call_date",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NumChecksFilter(admin.SimpleListFilter):
|
||||||
|
title = "check count"
|
||||||
|
|
||||||
|
parameter_name = "num_checks"
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
return (
|
||||||
|
("10", "more than 10"),
|
||||||
|
("20", "more than 20"),
|
||||||
|
("50", "more than 50"),
|
||||||
|
("100", "more than 100"),
|
||||||
|
("500", "more than 500"),
|
||||||
|
("1000", "more than 1000"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if not self.value():
|
||||||
|
return
|
||||||
|
|
||||||
|
value = int(self.value())
|
||||||
|
return queryset.filter(num_checks__gt=value)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Profile)
|
@admin.register(Profile)
|
||||||
class ProfileAdmin(admin.ModelAdmin):
|
class ProfileAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
css = {
|
css = {"all": ("css/admin/profiles.css",)}
|
||||||
'all': ('css/admin/profiles.css',)
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly_fields = ("user", "email")
|
readonly_fields = ("user", "email")
|
||||||
raw_id_fields = ("current_team", )
|
|
||||||
list_select_related = ("user", )
|
|
||||||
list_display = ("id", "users", "checks", "invited",
|
|
||||||
"reports_allowed", "ping_log_limit", "sms")
|
|
||||||
search_fields = ["id", "user__email"]
|
search_fields = ["id", "user__email"]
|
||||||
list_filter = ("team_limit", "reports_allowed",
|
list_per_page = 30
|
||||||
"check_limit", "next_report_date")
|
list_select_related = ("user",)
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"email",
|
||||||
|
"checks",
|
||||||
|
"date_joined",
|
||||||
|
"last_active_date",
|
||||||
|
"projects",
|
||||||
|
"invited",
|
||||||
|
"sms",
|
||||||
|
"reports",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
"user__date_joined",
|
||||||
|
"last_active_date",
|
||||||
|
"reports",
|
||||||
|
"check_limit",
|
||||||
|
NumChecksFilter,
|
||||||
|
"theme",
|
||||||
|
)
|
||||||
|
actions = ("login",)
|
||||||
|
|
||||||
fieldsets = (ProfileFieldset.tuple(), TeamFieldset.tuple())
|
fieldsets = (ProfileFieldset.tuple(), TeamFieldset.tuple())
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super(ProfileAdmin, self).get_queryset(request)
|
qs = super(ProfileAdmin, self).get_queryset(request)
|
||||||
qs = qs.annotate(Count("member", distinct=True))
|
qs = qs.prefetch_related("user__project_set")
|
||||||
qs = qs.annotate(Count("user__check", distinct=True))
|
qs = qs.annotate(num_members=Count("user__project__member", distinct=True))
|
||||||
|
qs = qs.annotate(num_checks=Count("user__project__check", distinct=True))
|
||||||
|
qs = qs.annotate(plan=F("user__subscription__plan_name"))
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@mark_safe
|
@mark_safe
|
||||||
def users(self, obj):
|
def email(self, obj):
|
||||||
if obj.member__count == 0:
|
s = escape(obj.user.email)
|
||||||
return obj.user.email
|
if obj.plan:
|
||||||
else:
|
s = "%s <span>%s</span>" % (s, obj.plan)
|
||||||
return render_to_string("admin/profile_list_team.html", {
|
|
||||||
"profile": obj
|
return s
|
||||||
})
|
|
||||||
|
def date_joined(self, obj):
|
||||||
|
return obj.user.date_joined
|
||||||
|
|
||||||
|
@mark_safe
|
||||||
|
def projects(self, obj):
|
||||||
|
return render_to_string("admin/profile_list_projects.html", {"profile": obj})
|
||||||
|
|
||||||
@mark_safe
|
@mark_safe
|
||||||
def checks(self, obj):
|
def checks(self, obj):
|
||||||
num_checks = obj.user__check__count
|
s = "%d of %d" % (obj.num_checks, obj.check_limit)
|
||||||
pct = 100 * num_checks / max(obj.check_limit, 1)
|
if obj.num_checks > 1:
|
||||||
pct = min(100, int(pct))
|
s = "<b>%s</b>" % s
|
||||||
|
return s
|
||||||
return """
|
|
||||||
<span class="bar"><span style="width: %dpx"></span></span>
|
|
||||||
%d of %d
|
|
||||||
""" % (pct, num_checks, obj.check_limit)
|
|
||||||
|
|
||||||
def invited(self, obj):
|
def invited(self, obj):
|
||||||
return "%d of %d" % (obj.member__count, obj.team_limit)
|
return "%d of %d" % (obj.num_members, obj.team_limit)
|
||||||
|
|
||||||
def sms(self, obj):
|
def sms(self, obj):
|
||||||
return "%d of %d" % (obj.sms_sent, obj.sms_limit)
|
return "%d of %d" % (obj.sms_sent, obj.sms_limit)
|
||||||
|
|
||||||
|
def login(self, request, qs):
|
||||||
|
profile = qs.get()
|
||||||
|
auth_login(request, profile.user, "hc.accounts.backends.EmailBackend")
|
||||||
|
return redirect("hc-index")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Project)
|
||||||
|
class ProjectAdmin(admin.ModelAdmin):
|
||||||
|
readonly_fields = ("code", "owner")
|
||||||
|
list_select_related = ("owner",)
|
||||||
|
list_display = ("id", "name_", "users", "usage", "switch")
|
||||||
|
search_fields = ["id", "name", "owner__email"]
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {"all": ("css/admin/projects.css",)}
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
qs = super(ProjectAdmin, self).get_queryset(request)
|
||||||
|
qs = qs.annotate(num_channels=Count("channel", distinct=True))
|
||||||
|
qs = qs.annotate(num_checks=Count("check", distinct=True))
|
||||||
|
qs = qs.annotate(num_members=Count("member", distinct=True))
|
||||||
|
return qs
|
||||||
|
|
||||||
|
def name_(self, obj):
|
||||||
|
if obj.name:
|
||||||
|
return obj.name
|
||||||
|
|
||||||
|
return "Default Project for %s" % obj.owner.email
|
||||||
|
|
||||||
|
@mark_safe
|
||||||
|
def users(self, obj):
|
||||||
|
if obj.num_members == 0:
|
||||||
|
return obj.owner.email
|
||||||
|
else:
|
||||||
|
return render_to_string("admin/project_list_team.html", {"project": obj})
|
||||||
|
|
||||||
def email(self, obj):
|
def email(self, obj):
|
||||||
return obj.user.email
|
return obj.owner.email
|
||||||
|
|
||||||
|
def usage(self, obj):
|
||||||
|
return _format_usage(obj.num_checks, obj.num_channels)
|
||||||
|
|
||||||
|
@mark_safe
|
||||||
|
def switch(self, obj):
|
||||||
|
url = reverse("hc-checks", args=[obj.code])
|
||||||
|
return "<a href='%s'>Show Checks</a>" % url
|
||||||
|
|
||||||
|
|
||||||
class HcUserAdmin(UserAdmin):
|
class HcUserAdmin(UserAdmin):
|
||||||
actions = ["send_report"]
|
actions = ["send_report", "send_nag"]
|
||||||
list_display = ('id', 'email', 'date_joined', 'engagement',
|
list_display = (
|
||||||
'is_staff', 'checks')
|
"id",
|
||||||
|
"email",
|
||||||
|
"usage",
|
||||||
|
"date_joined",
|
||||||
|
"last_login",
|
||||||
|
"is_staff",
|
||||||
|
)
|
||||||
|
|
||||||
|
list_display_links = ("id", "email")
|
||||||
list_filter = ("last_login", "date_joined", "is_staff", "is_active")
|
list_filter = ("last_login", "date_joined", "is_staff", "is_active")
|
||||||
|
|
||||||
ordering = ["-id"]
|
ordering = ["-id"]
|
||||||
|
|
||||||
def engagement(self, user):
|
def get_queryset(self, request):
|
||||||
result = ""
|
qs = super().get_queryset(request)
|
||||||
num_checks = Check.objects.filter(user=user).count()
|
qs = qs.annotate(num_checks=Count("project__check", distinct=True))
|
||||||
num_channels = Channel.objects.filter(user=user).count()
|
qs = qs.annotate(num_channels=Count("project__channel", distinct=True))
|
||||||
|
|
||||||
if num_checks == 0:
|
return qs
|
||||||
result += "0 checks, "
|
|
||||||
elif num_checks == 1:
|
|
||||||
result += "1 check, "
|
|
||||||
else:
|
|
||||||
result += "<strong>%d checks</strong>, " % num_checks
|
|
||||||
|
|
||||||
if num_channels == 0:
|
@mark_safe
|
||||||
result += "0 channels"
|
def usage(self, user):
|
||||||
elif num_channels == 1:
|
return _format_usage(user.num_checks, user.num_channels)
|
||||||
result += "1 channel, "
|
|
||||||
else:
|
|
||||||
result += "<strong>%d channels</strong>, " % num_channels
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
engagement.allow_tags = True
|
|
||||||
|
|
||||||
def checks(self, user):
|
|
||||||
url = reverse("hc-switch-team", args=[user.username])
|
|
||||||
return "<a href='%s'>Checks</a>" % url
|
|
||||||
|
|
||||||
checks.allow_tags = True
|
|
||||||
|
|
||||||
def send_report(self, request, qs):
|
def send_report(self, request, qs):
|
||||||
for user in qs:
|
for user in qs:
|
||||||
@ -131,6 +244,22 @@ class HcUserAdmin(UserAdmin):
|
|||||||
|
|
||||||
self.message_user(request, "%d email(s) sent" % qs.count())
|
self.message_user(request, "%d email(s) sent" % qs.count())
|
||||||
|
|
||||||
|
def send_nag(self, request, qs):
|
||||||
|
for user in qs:
|
||||||
|
user.profile.send_report(nag=True)
|
||||||
|
|
||||||
|
self.message_user(request, "%d email(s) sent" % qs.count())
|
||||||
|
|
||||||
|
|
||||||
admin.site.unregister(User)
|
admin.site.unregister(User)
|
||||||
admin.site.register(User, HcUserAdmin)
|
admin.site.register(User, HcUserAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Credential)
|
||||||
|
class CredentialAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "created", "email", "name")
|
||||||
|
search_fields = ["id", "code", "name", "user__email"]
|
||||||
|
list_filter = ["created"]
|
||||||
|
|
||||||
|
def email(self, obj):
|
||||||
|
return obj.user.email
|
||||||
|
@ -1,19 +1,21 @@
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.conf import settings
|
||||||
from hc.accounts.models import Profile
|
from hc.accounts.models import Profile
|
||||||
|
from hc.accounts.views import _make_user
|
||||||
|
|
||||||
|
|
||||||
class BasicBackend(object):
|
class BasicBackend(object):
|
||||||
|
|
||||||
def get_user(self, user_id):
|
def get_user(self, user_id):
|
||||||
try:
|
try:
|
||||||
return User.objects.select_related("profile").get(pk=user_id)
|
q = User.objects.select_related("profile")
|
||||||
|
|
||||||
|
return q.get(pk=user_id)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Authenticate against the token in user's profile.
|
# Authenticate against the token in user's profile.
|
||||||
class ProfileBackend(BasicBackend):
|
class ProfileBackend(BasicBackend):
|
||||||
|
|
||||||
def authenticate(self, request=None, username=None, token=None):
|
def authenticate(self, request=None, username=None, token=None):
|
||||||
try:
|
try:
|
||||||
profiles = Profile.objects.select_related("user")
|
profiles = Profile.objects.select_related("user")
|
||||||
@ -28,7 +30,6 @@ class ProfileBackend(BasicBackend):
|
|||||||
|
|
||||||
|
|
||||||
class EmailBackend(BasicBackend):
|
class EmailBackend(BasicBackend):
|
||||||
|
|
||||||
def authenticate(self, request=None, username=None, password=None):
|
def authenticate(self, request=None, username=None, password=None):
|
||||||
try:
|
try:
|
||||||
user = User.objects.get(email=username)
|
user = User.objects.get(email=username)
|
||||||
@ -37,3 +38,31 @@ class EmailBackend(BasicBackend):
|
|||||||
|
|
||||||
if user.check_password(password):
|
if user.check_password(password):
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class CustomHeaderBackend(BasicBackend):
|
||||||
|
"""
|
||||||
|
This backend works in conjunction with the ``CustomHeaderMiddleware``,
|
||||||
|
and is used when the server is handling authentication outside of Django.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def authenticate(self, request, remote_user_email):
|
||||||
|
"""
|
||||||
|
The email address passed as remote_user_email is considered trusted.
|
||||||
|
Return the User object with the given email address. Create a new User
|
||||||
|
if it does not exist.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# This backend should only be used when header-based authentication is enabled
|
||||||
|
assert settings.REMOTE_USER_HEADER
|
||||||
|
# remote_user_email should have a value
|
||||||
|
assert remote_user_email
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.objects.get(email=remote_user_email)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
user = _make_user(remote_user_email)
|
||||||
|
|
||||||
|
return user
|
||||||
|
51
hc/accounts/decorators.py
Normal file
51
hc/accounts/decorators.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
from functools import wraps
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from django.core.signing import TimestampSigner, SignatureExpired
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
from hc.api.models import TokenBucket
|
||||||
|
from hc.lib import emails
|
||||||
|
|
||||||
|
|
||||||
|
def _session_unsign(request, key, max_age):
|
||||||
|
if key not in request.session:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return TimestampSigner().unsign(request.session[key], max_age=max_age)
|
||||||
|
except SignatureExpired:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def require_sudo_mode(f):
|
||||||
|
@wraps(f)
|
||||||
|
def wrapper(request, *args, **kwds):
|
||||||
|
assert request.user.is_authenticated
|
||||||
|
|
||||||
|
# is sudo mode active and has not expired yet?
|
||||||
|
if _session_unsign(request, "sudo", 1800) == "active":
|
||||||
|
return f(request, *args, **kwds)
|
||||||
|
|
||||||
|
if not TokenBucket.authorize_sudo_code(request.user):
|
||||||
|
return render(request, "try_later.html")
|
||||||
|
|
||||||
|
# has the user submitted a code to enter sudo mode?
|
||||||
|
if "sudo_code" in request.POST:
|
||||||
|
ours = _session_unsign(request, "sudo_code", 900)
|
||||||
|
if ours and ours == request.POST["sudo_code"]:
|
||||||
|
request.session.pop("sudo_code")
|
||||||
|
request.session["sudo"] = TimestampSigner().sign("active")
|
||||||
|
return redirect(request.path)
|
||||||
|
|
||||||
|
if not _session_unsign(request, "sudo_code", 900):
|
||||||
|
code = "%06d" % secrets.randbelow(1000000)
|
||||||
|
request.session["sudo_code"] = TimestampSigner().sign(code)
|
||||||
|
emails.sudo_code(request.user.email, {"sudo_code": code})
|
||||||
|
|
||||||
|
ctx = {}
|
||||||
|
if "sudo_code" in request.POST:
|
||||||
|
ctx["wrong_code"] = True
|
||||||
|
|
||||||
|
return render(request, "accounts/sudo.html", ctx)
|
||||||
|
|
||||||
|
return wrapper
|
@ -1,23 +1,104 @@
|
|||||||
|
import base64
|
||||||
|
import binascii
|
||||||
from datetime import timedelta as td
|
from datetime import timedelta as td
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.contrib.auth import authenticate
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from hc.accounts.models import REPORT_CHOICES, Member
|
||||||
|
from hc.api.models import TokenBucket
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
|
||||||
class LowercaseEmailField(forms.EmailField):
|
class LowercaseEmailField(forms.EmailField):
|
||||||
|
|
||||||
def clean(self, value):
|
def clean(self, value):
|
||||||
value = super(LowercaseEmailField, self).clean(value)
|
value = super(LowercaseEmailField, self).clean(value)
|
||||||
return value.lower()
|
return value.lower()
|
||||||
|
|
||||||
|
|
||||||
class EmailPasswordForm(forms.Form):
|
class Base64Field(forms.CharField):
|
||||||
|
def to_python(self, value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return base64.b64decode(value.encode())
|
||||||
|
except binascii.Error:
|
||||||
|
raise ValidationError(message="Cannot decode base64")
|
||||||
|
|
||||||
|
|
||||||
|
class SignupForm(forms.Form):
|
||||||
|
# Call it "identity" instead of "email"
|
||||||
|
# to avoid some of the dumber bots
|
||||||
|
identity = LowercaseEmailField(
|
||||||
|
error_messages={"required": "Please enter your email address."}
|
||||||
|
)
|
||||||
|
tz = forms.CharField(required=False)
|
||||||
|
|
||||||
|
def clean_identity(self):
|
||||||
|
v = self.cleaned_data["identity"]
|
||||||
|
if len(v) > 254:
|
||||||
|
raise forms.ValidationError("Address is too long.")
|
||||||
|
|
||||||
|
if User.objects.filter(email=v).exists():
|
||||||
|
raise forms.ValidationError(
|
||||||
|
"An account with this email address already exists."
|
||||||
|
)
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
def clean_tz(self):
|
||||||
|
# Declare tz as "clean" only if we can find it in pytz.all_timezones
|
||||||
|
if self.cleaned_data["tz"] in pytz.all_timezones:
|
||||||
|
return self.cleaned_data["tz"]
|
||||||
|
|
||||||
|
# Otherwise, return None, and *don't* throw a validation exception:
|
||||||
|
# If user's browser reports a timezone we don't recognize, we
|
||||||
|
# should ignore the timezone but still save the rest of the form.
|
||||||
|
|
||||||
|
|
||||||
|
class EmailLoginForm(forms.Form):
|
||||||
|
# Call it "identity" instead of "email"
|
||||||
|
# to avoid some of the dumber bots
|
||||||
|
identity = LowercaseEmailField()
|
||||||
|
|
||||||
|
def clean_identity(self):
|
||||||
|
v = self.cleaned_data["identity"]
|
||||||
|
if not TokenBucket.authorize_login_email(v):
|
||||||
|
raise forms.ValidationError("Too many attempts, please try later.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.user = User.objects.get(email=v)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
raise forms.ValidationError("Unknown email address.")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordLoginForm(forms.Form):
|
||||||
email = LowercaseEmailField()
|
email = LowercaseEmailField()
|
||||||
password = forms.CharField(required=False)
|
password = forms.CharField()
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
username = self.cleaned_data.get("email")
|
||||||
|
password = self.cleaned_data.get("password")
|
||||||
|
|
||||||
|
if username and password:
|
||||||
|
if not TokenBucket.authorize_login_password(username):
|
||||||
|
raise forms.ValidationError("Too many attempts, please try later.")
|
||||||
|
|
||||||
|
self.user = authenticate(username=username, password=password)
|
||||||
|
if self.user is None or not self.user.is_active:
|
||||||
|
raise forms.ValidationError("Incorrect email or password.")
|
||||||
|
|
||||||
|
return self.cleaned_data
|
||||||
|
|
||||||
|
|
||||||
class ReportSettingsForm(forms.Form):
|
class ReportSettingsForm(forms.Form):
|
||||||
reports_allowed = forms.BooleanField(required=False)
|
reports = forms.ChoiceField(choices=REPORT_CHOICES)
|
||||||
nag_period = forms.IntegerField(min_value=0, max_value=86400)
|
nag_period = forms.IntegerField(min_value=0, max_value=86400)
|
||||||
|
tz = forms.CharField()
|
||||||
|
|
||||||
def clean_nag_period(self):
|
def clean_nag_period(self):
|
||||||
seconds = self.cleaned_data["nag_period"]
|
seconds = self.cleaned_data["nag_period"]
|
||||||
@ -27,9 +108,18 @@ class ReportSettingsForm(forms.Form):
|
|||||||
|
|
||||||
return td(seconds=seconds)
|
return td(seconds=seconds)
|
||||||
|
|
||||||
|
def clean_tz(self):
|
||||||
|
# Declare tz as "clean" only if we can find it in pytz.all_timezones
|
||||||
|
if self.cleaned_data["tz"] in pytz.all_timezones:
|
||||||
|
return self.cleaned_data["tz"]
|
||||||
|
|
||||||
|
# Otherwise, return None, and *don't* throw a validation exception:
|
||||||
|
# If user's browser reports a timezone we don't recognize, we
|
||||||
|
# should ignore the timezone but still save the rest of the form.
|
||||||
|
|
||||||
|
|
||||||
class SetPasswordForm(forms.Form):
|
class SetPasswordForm(forms.Form):
|
||||||
password = forms.CharField()
|
password = forms.CharField(min_length=8)
|
||||||
|
|
||||||
|
|
||||||
class ChangeEmailForm(forms.Form):
|
class ChangeEmailForm(forms.Form):
|
||||||
@ -39,18 +129,51 @@ class ChangeEmailForm(forms.Form):
|
|||||||
def clean_email(self):
|
def clean_email(self):
|
||||||
v = self.cleaned_data["email"]
|
v = self.cleaned_data["email"]
|
||||||
if User.objects.filter(email=v).exists():
|
if User.objects.filter(email=v).exists():
|
||||||
raise forms.ValidationError("%s is not available" % v)
|
raise forms.ValidationError("%s is already registered" % v)
|
||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
class InviteTeamMemberForm(forms.Form):
|
class InviteTeamMemberForm(forms.Form):
|
||||||
email = LowercaseEmailField()
|
email = LowercaseEmailField(max_length=254)
|
||||||
|
role = forms.ChoiceField(choices=Member.Role.choices)
|
||||||
|
|
||||||
|
|
||||||
class RemoveTeamMemberForm(forms.Form):
|
class RemoveTeamMemberForm(forms.Form):
|
||||||
email = LowercaseEmailField()
|
email = LowercaseEmailField()
|
||||||
|
|
||||||
|
|
||||||
class TeamNameForm(forms.Form):
|
class ProjectNameForm(forms.Form):
|
||||||
team_name = forms.CharField(max_length=200, required=True)
|
name = forms.CharField(max_length=60)
|
||||||
|
|
||||||
|
|
||||||
|
class TransferForm(forms.Form):
|
||||||
|
email = LowercaseEmailField()
|
||||||
|
|
||||||
|
|
||||||
|
class AddWebAuthnForm(forms.Form):
|
||||||
|
name = forms.CharField(max_length=100)
|
||||||
|
client_data_json = Base64Field()
|
||||||
|
attestation_object = Base64Field()
|
||||||
|
|
||||||
|
|
||||||
|
class WebAuthnForm(forms.Form):
|
||||||
|
credential_id = Base64Field()
|
||||||
|
client_data_json = Base64Field()
|
||||||
|
authenticator_data = Base64Field()
|
||||||
|
signature = Base64Field()
|
||||||
|
|
||||||
|
|
||||||
|
class TotpForm(forms.Form):
|
||||||
|
error_css_class = "has-error"
|
||||||
|
code = forms.RegexField(regex=r"^\d{6}$")
|
||||||
|
|
||||||
|
def __init__(self, totp, post=None, files=None):
|
||||||
|
self.totp = totp
|
||||||
|
super(TotpForm, self).__init__(post, files)
|
||||||
|
|
||||||
|
def clean_code(self):
|
||||||
|
if not self.totp.verify(self.cleaned_data["code"], valid_window=1):
|
||||||
|
raise forms.ValidationError("The code you entered was incorrect.")
|
||||||
|
|
||||||
|
return self.cleaned_data["code"]
|
||||||
|
42
hc/accounts/management/commands/createsuperuser.py
Normal file
42
hc/accounts/management/commands/createsuperuser.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import getpass
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from hc.accounts.forms import SignupForm
|
||||||
|
from hc.accounts.views import _make_user
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = """Create a super-user account."""
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
email = None
|
||||||
|
password = None
|
||||||
|
|
||||||
|
while not email:
|
||||||
|
raw = input("Email address:")
|
||||||
|
form = SignupForm({"identity": raw})
|
||||||
|
if not form.is_valid():
|
||||||
|
self.stderr.write("Error: " + " ".join(form.errors["identity"]))
|
||||||
|
continue
|
||||||
|
|
||||||
|
email = form.cleaned_data["identity"]
|
||||||
|
|
||||||
|
while not password:
|
||||||
|
p1 = getpass.getpass()
|
||||||
|
p2 = getpass.getpass("Password (again):")
|
||||||
|
if p1.strip() == "":
|
||||||
|
self.stderr.write("Error: Blank passwords aren't allowed.")
|
||||||
|
continue
|
||||||
|
if p1 != p2:
|
||||||
|
self.stderr.write("Error: Your passwords didn't match.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
password = p1
|
||||||
|
|
||||||
|
user = _make_user(email)
|
||||||
|
user.set_password(password)
|
||||||
|
user.is_staff = True
|
||||||
|
user.is_superuser = True
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
return "Superuser created successfully."
|
@ -2,40 +2,42 @@ from datetime import timedelta
|
|||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db.models import Count
|
from django.db.models import Count, F
|
||||||
from django.utils import timezone
|
from django.utils.timezone import now
|
||||||
|
from hc.accounts.models import Profile
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = """Prune old, inactive user accounts.
|
help = """Prune old, inactive user accounts.
|
||||||
|
|
||||||
Conditions for removing an user account:
|
Conditions for removing an user account:
|
||||||
- created 6 months ago and never logged in. Does not belong
|
- created 1 month ago and never logged in. Does not belong
|
||||||
to any team.
|
to any team.
|
||||||
Use case: visitor types in their email at the website but
|
Use case: visitor types in their email at the website but
|
||||||
never follows through with login.
|
never follows through with login.
|
||||||
|
|
||||||
- not logged in for 6 months, and has no checks. Does not
|
|
||||||
belong to any team.
|
|
||||||
Use case: user wants to remove their account. So they
|
|
||||||
remove all checks and leave the account at that.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
cutoff = timezone.now() - timedelta(days=180)
|
month_ago = now() - timedelta(days=30)
|
||||||
|
|
||||||
# Old accounts, never logged in, no team memberships
|
# Old accounts, never logged in, no team memberships
|
||||||
q = User.objects
|
q = User.objects.order_by("id")
|
||||||
q = q.annotate(n_teams=Count("memberships"))
|
q = q.annotate(n_teams=Count("memberships"))
|
||||||
q = q.filter(date_joined__lt=cutoff, last_login=None, n_teams=0)
|
q = q.filter(date_joined__lt=month_ago, last_login=None, n_teams=0)
|
||||||
n1, _ = q.delete()
|
|
||||||
|
|
||||||
# Not logged in for 1 month, 0 checks, no team memberships
|
n, summary = q.delete()
|
||||||
q = User.objects
|
count = summary.get("auth.User", 0)
|
||||||
q = q.annotate(n_checks=Count("check"))
|
self.stdout.write("Pruned %d never-logged-in user accounts." % count)
|
||||||
q = q.annotate(n_teams=Count("memberships"))
|
|
||||||
q = q.filter(last_login__lt=cutoff, n_checks=0, n_teams=0)
|
|
||||||
n2, _ = q.delete()
|
|
||||||
|
|
||||||
return "Done! Pruned %d user accounts." % (n1 + n2)
|
# Profiles scheduled for deletion
|
||||||
|
q = Profile.objects.order_by("id")
|
||||||
|
q = q.filter(deletion_notice_date__lt=month_ago)
|
||||||
|
# Exclude users who have logged in after receiving deletion notice
|
||||||
|
q = q.exclude(user__last_login__gt=F("deletion_notice_date"))
|
||||||
|
|
||||||
|
for profile in q:
|
||||||
|
self.stdout.write("Deleting inactive %s" % profile.user.email)
|
||||||
|
profile.user.delete()
|
||||||
|
|
||||||
|
return "Done!"
|
||||||
|
77
hc/accounts/management/commands/senddeletionnotices.py
Normal file
77
hc/accounts/management/commands/senddeletionnotices.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
import time
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from hc.accounts.models import Profile, Member
|
||||||
|
from hc.api.models import Ping
|
||||||
|
from hc.lib import emails
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = """Send deletion notices to inactive user accounts.
|
||||||
|
|
||||||
|
Conditions for sending the notice:
|
||||||
|
- deletion notice has not been sent recently
|
||||||
|
- last login more than a year ago
|
||||||
|
- none of the owned projects has invited team members
|
||||||
|
- none of the owned projects has pings in the last year
|
||||||
|
- is on a free plan
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
year_ago = now() - timedelta(days=365)
|
||||||
|
|
||||||
|
q = Profile.objects.order_by("id")
|
||||||
|
# Exclude accounts with logins in the last year
|
||||||
|
q = q.exclude(user__last_login__gt=year_ago)
|
||||||
|
# Exclude accounts less than a year old
|
||||||
|
q = q.exclude(user__date_joined__gt=year_ago)
|
||||||
|
# Exclude accounts with the deletion notice already sent
|
||||||
|
q = q.exclude(deletion_notice_date__gt=year_ago)
|
||||||
|
# Exclude accounts with activity in the last year
|
||||||
|
q = q.exclude(last_active_date__gt=year_ago)
|
||||||
|
# Exclude paid accounts
|
||||||
|
q = q.exclude(sms_limit__gt=5)
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
skipped_has_team = 0
|
||||||
|
skipped_has_pings = 0
|
||||||
|
|
||||||
|
for profile in q:
|
||||||
|
members = Member.objects.filter(project__owner_id=profile.user_id)
|
||||||
|
if members.exists():
|
||||||
|
# Don't send deletion notice: this account has team members
|
||||||
|
skipped_has_team += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
pings = Ping.objects.filter(owner__project__owner_id=profile.user_id)
|
||||||
|
pings = pings.filter(created__gt=year_ago)
|
||||||
|
if pings.exists():
|
||||||
|
# Don't send deletion notice: this account has pings in the last year
|
||||||
|
skipped_has_pings += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.stdout.write("Sending notice to %s" % profile.user.email)
|
||||||
|
|
||||||
|
profile.deletion_notice_date = now()
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
ctx = {"email": profile.user.email, "support_email": settings.SUPPORT_EMAIL}
|
||||||
|
emails.deletion_notice(profile.user.email, ctx)
|
||||||
|
sent += 1
|
||||||
|
|
||||||
|
# Throttle so we don't send too many emails at once:
|
||||||
|
self.pause()
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"Done!\n"
|
||||||
|
f"* Notices sent: {sent}\n"
|
||||||
|
f"* Skipped (has team members): {skipped_has_team}\n"
|
||||||
|
f"* Skipped (has pings in the last year): {skipped_has_pings}\n"
|
||||||
|
)
|
@ -1,3 +1,7 @@
|
|||||||
|
from django.contrib import auth
|
||||||
|
from django.contrib.auth.middleware import RemoteUserMiddleware
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from hc.accounts.models import Profile
|
from hc.accounts.models import Profile
|
||||||
|
|
||||||
|
|
||||||
@ -9,11 +13,51 @@ class TeamAccessMiddleware(object):
|
|||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return self.get_response(request)
|
return self.get_response(request)
|
||||||
|
|
||||||
teams_q = Profile.objects.filter(member__user_id=request.user.id)
|
|
||||||
teams_q = teams_q.select_related("user")
|
|
||||||
request.get_teams = lambda: list(teams_q)
|
|
||||||
|
|
||||||
request.profile = Profile.objects.for_user(request.user)
|
request.profile = Profile.objects.for_user(request.user)
|
||||||
request.team = request.profile.team()
|
|
||||||
|
|
||||||
return self.get_response(request)
|
return self.get_response(request)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomHeaderMiddleware(RemoteUserMiddleware):
|
||||||
|
"""
|
||||||
|
Middleware for utilizing Web-server-provided authentication.
|
||||||
|
|
||||||
|
If request.user is not authenticated, then this middleware:
|
||||||
|
- looks for an email address in request.META[settings.REMOTE_USER_HEADER]
|
||||||
|
- looks up and automatically logs in the user with a matching email
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def process_request(self, request):
|
||||||
|
if not settings.REMOTE_USER_HEADER:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Make sure AuthenticationMiddleware is installed
|
||||||
|
assert hasattr(request, "user")
|
||||||
|
|
||||||
|
email = request.META.get(settings.REMOTE_USER_HEADER)
|
||||||
|
if not email:
|
||||||
|
# If specified header doesn't exist or is empty then log out any
|
||||||
|
# authenticated user and return
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
auth.logout(request)
|
||||||
|
return
|
||||||
|
|
||||||
|
# If the user is already authenticated and that user is the user we are
|
||||||
|
# getting passed in the headers, then the correct user is already
|
||||||
|
# persisted in the session and we don't need to continue.
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
if request.user.email == email:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# An authenticated user is associated with the request, but
|
||||||
|
# it does not match the authorized user in the header.
|
||||||
|
auth.logout(request)
|
||||||
|
|
||||||
|
# We are seeing this user for the first time in this session, attempt
|
||||||
|
# to authenticate the user.
|
||||||
|
user = auth.authenticate(request, remote_user_email=email)
|
||||||
|
if user:
|
||||||
|
# User is valid. Set request.user and persist user in the session
|
||||||
|
# by logging the user in.
|
||||||
|
request.user = user
|
||||||
|
auth.login(request, user)
|
||||||
|
@ -7,18 +7,32 @@ from django.conf import settings
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Profile',
|
name="Profile",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, serialize=False, verbose_name='ID', primary_key=True)),
|
(
|
||||||
('next_report_date', models.DateTimeField(null=True, blank=True)),
|
"id",
|
||||||
('reports_allowed', models.BooleanField(default=True)),
|
models.AutoField(
|
||||||
('user', models.OneToOneField(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)),
|
auto_created=True,
|
||||||
],
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
primary_key=True,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
("next_report_date", models.DateTimeField(null=True, blank=True)),
|
||||||
|
("reports_allowed", models.BooleanField(default=True)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.OneToOneField(
|
||||||
|
blank=True,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
@ -6,14 +6,12 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("accounts", "0001_initial")]
|
||||||
('accounts', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='profile',
|
model_name="profile",
|
||||||
name='ping_log_limit',
|
name="ping_log_limit",
|
||||||
field=models.IntegerField(default=100),
|
field=models.IntegerField(default=100),
|
||||||
),
|
)
|
||||||
]
|
]
|
||||||
|
@ -7,14 +7,12 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("accounts", "0002_profile_ping_log_limit")]
|
||||||
('accounts', '0002_profile_ping_log_limit'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='profile',
|
model_name="profile",
|
||||||
name='token',
|
name="token",
|
||||||
field=models.CharField(blank=True, max_length=128),
|
field=models.CharField(blank=True, max_length=128),
|
||||||
),
|
)
|
||||||
]
|
]
|
||||||
|
@ -7,14 +7,12 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("accounts", "0003_profile_token")]
|
||||||
('accounts', '0003_profile_token'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='profile',
|
model_name="profile",
|
||||||
name='api_key',
|
name="api_key",
|
||||||
field=models.CharField(blank=True, max_length=128),
|
field=models.CharField(blank=True, max_length=128),
|
||||||
),
|
)
|
||||||
]
|
]
|
||||||
|
@ -11,34 +11,46 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
('accounts', '0004_profile_api_key'),
|
("accounts", "0004_profile_api_key"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Member',
|
name="Member",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='profile',
|
model_name="profile",
|
||||||
name='team_access_allowed',
|
name="team_access_allowed",
|
||||||
field=models.BooleanField(default=False),
|
field=models.BooleanField(default=False),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='profile',
|
model_name="profile",
|
||||||
name='team_name',
|
name="team_name",
|
||||||
field=models.CharField(blank=True, max_length=200),
|
field=models.CharField(blank=True, max_length=200),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='member',
|
model_name="member",
|
||||||
name='team',
|
name="team",
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Profile'),
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to="accounts.Profile"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='member',
|
model_name="member",
|
||||||
name='user',
|
name="user",
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -8,14 +8,16 @@ import django.db.models.deletion
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("accounts", "0005_auto_20160509_0801")]
|
||||||
('accounts', '0005_auto_20160509_0801'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='profile',
|
model_name="profile",
|
||||||
name='current_team',
|
name="current_team",
|
||||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.Profile'),
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="accounts.Profile",
|
||||||
),
|
),
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
@ -7,14 +7,12 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("accounts", "0006_profile_current_team")]
|
||||||
('accounts', '0006_profile_current_team'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='profile',
|
model_name="profile",
|
||||||
name='check_limit',
|
name="check_limit",
|
||||||
field=models.IntegerField(default=20),
|
field=models.IntegerField(default=20),
|
||||||
),
|
)
|
||||||
]
|
]
|
||||||
|
@ -7,14 +7,10 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("accounts", "0007_profile_check_limit")]
|
||||||
('accounts', '0007_profile_check_limit'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='profile',
|
model_name="profile", name="bill_to", field=models.TextField(blank=True)
|
||||||
name='bill_to',
|
)
|
||||||
field=models.TextField(blank=True),
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
@ -7,24 +7,18 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("accounts", "0008_profile_bill_to")]
|
||||||
('accounts', '0008_profile_bill_to'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='profile',
|
model_name="profile",
|
||||||
name='last_sms_date',
|
name="last_sms_date",
|
||||||
field=models.DateTimeField(blank=True, null=True),
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='profile',
|
model_name="profile", name="sms_limit", field=models.IntegerField(default=0)
|
||||||
name='sms_limit',
|
|
||||||
field=models.IntegerField(default=0),
|
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='profile',
|
model_name="profile", name="sms_sent", field=models.IntegerField(default=0)
|
||||||
name='sms_sent',
|
|
||||||
field=models.IntegerField(default=0),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -7,14 +7,12 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("accounts", "0009_auto_20170714_1734")]
|
||||||
('accounts', '0009_auto_20170714_1734'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='profile',
|
model_name="profile",
|
||||||
name='team_limit',
|
name="team_limit",
|
||||||
field=models.IntegerField(default=2),
|
field=models.IntegerField(default=2),
|
||||||
),
|
)
|
||||||
]
|
]
|
||||||
|
@ -7,14 +7,12 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("accounts", "0010_profile_team_limit")]
|
||||||
('accounts', '0010_profile_team_limit'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='profile',
|
model_name="profile",
|
||||||
name='sort',
|
name="sort",
|
||||||
field=models.CharField(default='created', max_length=20),
|
field=models.CharField(default="created", max_length=20),
|
||||||
),
|
)
|
||||||
]
|
]
|
||||||
|
@ -8,19 +8,24 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("accounts", "0011_profile_sort")]
|
||||||
('accounts', '0011_profile_sort'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='profile',
|
model_name="profile",
|
||||||
name='nag_period',
|
name="nag_period",
|
||||||
field=models.DurationField(choices=[(datetime.timedelta(0), 'Disabled'), (datetime.timedelta(0, 3600), 'Hourly'), (datetime.timedelta(1), 'Daily')], default=datetime.timedelta(0)),
|
field=models.DurationField(
|
||||||
|
choices=[
|
||||||
|
(datetime.timedelta(0), "Disabled"),
|
||||||
|
(datetime.timedelta(0, 3600), "Hourly"),
|
||||||
|
(datetime.timedelta(1), "Daily"),
|
||||||
|
],
|
||||||
|
default=datetime.timedelta(0),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='profile',
|
model_name="profile",
|
||||||
name='next_nag_date',
|
name="next_nag_date",
|
||||||
field=models.DateTimeField(blank=True, null=True),
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -7,13 +7,8 @@ from django.db import migrations
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("accounts", "0012_auto_20171014_1002")]
|
||||||
('accounts', '0012_auto_20171014_1002'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(model_name="profile", name="team_access_allowed")
|
||||||
model_name='profile',
|
|
||||||
name='team_access_allowed',
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
@ -9,14 +9,16 @@ import django.db.models.deletion
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("accounts", "0013_remove_profile_team_access_allowed")]
|
||||||
('accounts', '0013_remove_profile_team_access_allowed'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='member',
|
model_name="member",
|
||||||
name='user',
|
name="user",
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to=settings.AUTH_USER_MODEL),
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="memberships",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
),
|
),
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
21
hc/accounts/migrations/0015_auto_20181029_1858.py
Normal file
21
hc/accounts/migrations/0015_auto_20181029_1858.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 2.1.2 on 2018-10-29 18:58
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [("accounts", "0014_auto_20171227_1530")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="profile",
|
||||||
|
name="api_key_id",
|
||||||
|
field=models.CharField(blank=True, max_length=128),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="profile",
|
||||||
|
name="api_key_readonly",
|
||||||
|
field=models.CharField(blank=True, max_length=128),
|
||||||
|
),
|
||||||
|
]
|
10
hc/accounts/migrations/0016_remove_profile_bill_to.py
Normal file
10
hc/accounts/migrations/0016_remove_profile_bill_to.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# Generated by Django 2.1.2 on 2018-11-06 08:17
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [("accounts", "0015_auto_20181029_1858")]
|
||||||
|
|
||||||
|
operations = [migrations.RemoveField(model_name="profile", name="bill_to")]
|
63
hc/accounts/migrations/0017_auto_20190112_1426.py
Normal file
63
hc/accounts/migrations/0017_auto_20190112_1426.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# Generated by Django 2.1.5 on 2019-01-12 14:26
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("accounts", "0016_remove_profile_bill_to"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Project",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"code",
|
||||||
|
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
("name", models.CharField(blank=True, max_length=200)),
|
||||||
|
("api_key", models.CharField(blank=True, max_length=128)),
|
||||||
|
("api_key_readonly", models.CharField(blank=True, max_length=128)),
|
||||||
|
(
|
||||||
|
"owner",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="member",
|
||||||
|
name="project",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="accounts.Project",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="profile",
|
||||||
|
name="current_project",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="accounts.Project",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
33
hc/accounts/migrations/0018_auto_20190112_1426.py
Normal file
33
hc/accounts/migrations/0018_auto_20190112_1426.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 2.1.5 on 2019-01-11 14:49
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def create_projects(apps, schema_editor):
|
||||||
|
Profile = apps.get_model("accounts", "Profile")
|
||||||
|
Project = apps.get_model("accounts", "Project")
|
||||||
|
Member = apps.get_model("accounts", "Member")
|
||||||
|
for profile in Profile.objects.all():
|
||||||
|
project = Project()
|
||||||
|
project.name = profile.team_name
|
||||||
|
project.owner_id = profile.user_id
|
||||||
|
project.api_key = profile.api_key
|
||||||
|
project.api_key_readonly = profile.api_key_readonly
|
||||||
|
project.save()
|
||||||
|
|
||||||
|
profile.current_project = project
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
Member.objects.filter(team=profile).update(project=project)
|
||||||
|
|
||||||
|
for profile in Profile.objects.all():
|
||||||
|
if profile.current_team_id:
|
||||||
|
profile.current_project = profile.current_team.current_project
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [("accounts", "0017_auto_20190112_1426")]
|
||||||
|
|
||||||
|
operations = [migrations.RunPython(create_projects, migrations.RunPython.noop)]
|
16
hc/accounts/migrations/0019_project_badge_key.py
Normal file
16
hc/accounts/migrations/0019_project_badge_key.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 2.1.5 on 2019-01-12 19:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [("accounts", "0018_auto_20190112_1426")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="project",
|
||||||
|
name="badge_key",
|
||||||
|
field=models.CharField(blank=True, max_length=150, null=True),
|
||||||
|
)
|
||||||
|
]
|
17
hc/accounts/migrations/0020_auto_20190112_1950.py
Normal file
17
hc/accounts/migrations/0020_auto_20190112_1950.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 2.1.5 on 2019-01-12 19:50
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def set_badge_key(apps, schema_editor):
|
||||||
|
Project = apps.get_model("accounts", "Project")
|
||||||
|
for project in Project.objects.select_related("owner").all():
|
||||||
|
project.badge_key = project.owner.username
|
||||||
|
project.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [("accounts", "0019_project_badge_key")]
|
||||||
|
|
||||||
|
operations = [migrations.RunPython(set_badge_key, migrations.RunPython.noop)]
|
16
hc/accounts/migrations/0021_auto_20190112_2005.py
Normal file
16
hc/accounts/migrations/0021_auto_20190112_2005.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 2.1.5 on 2019-01-12 20:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [("accounts", "0020_auto_20190112_1950")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="project",
|
||||||
|
name="badge_key",
|
||||||
|
field=models.CharField(max_length=150, unique=True),
|
||||||
|
)
|
||||||
|
]
|
19
hc/accounts/migrations/0022_auto_20190114_0857.py
Normal file
19
hc/accounts/migrations/0022_auto_20190114_0857.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 2.1.5 on 2019-01-14 08:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [("accounts", "0021_auto_20190112_2005")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="member",
|
||||||
|
name="project",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to="accounts.Project"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
14
hc/accounts/migrations/0023_auto_20190117_1419.py
Normal file
14
hc/accounts/migrations/0023_auto_20190117_1419.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Generated by Django 2.1.5 on 2019-01-17 14:19
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [("accounts", "0022_auto_20190114_0857")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(model_name="profile", name="api_key"),
|
||||||
|
migrations.RemoveField(model_name="profile", name="api_key_id"),
|
||||||
|
migrations.RemoveField(model_name="profile", name="api_key_readonly"),
|
||||||
|
]
|
13
hc/accounts/migrations/0024_auto_20190119_1540.py
Normal file
13
hc/accounts/migrations/0024_auto_20190119_1540.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Generated by Django 2.1.5 on 2019-01-19 15:40
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [("accounts", "0023_auto_20190117_1419")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(model_name="profile", name="current_team"),
|
||||||
|
migrations.RemoveField(model_name="profile", name="team_name"),
|
||||||
|
]
|
10
hc/accounts/migrations/0025_remove_member_team.py
Normal file
10
hc/accounts/migrations/0025_remove_member_team.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# Generated by Django 2.1.5 on 2019-01-22 08:33
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [("accounts", "0024_auto_20190119_1540")]
|
||||||
|
|
||||||
|
operations = [migrations.RemoveField(model_name="member", name="team")]
|
27
hc/accounts/migrations/0026_auto_20190204_2042.py
Normal file
27
hc/accounts/migrations/0026_auto_20190204_2042.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 2.1.5 on 2019-02-04 20:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [("accounts", "0025_remove_member_team")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="project",
|
||||||
|
name="api_key",
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=128),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="project",
|
||||||
|
name="api_key_readonly",
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=128),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="project",
|
||||||
|
name="code",
|
||||||
|
field=models.UUIDField(default=uuid.uuid4, unique=True),
|
||||||
|
),
|
||||||
|
]
|
16
hc/accounts/migrations/0027_profile_deletion_notice_date.py
Normal file
16
hc/accounts/migrations/0027_profile_deletion_notice_date.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 2.1.7 on 2019-03-12 17:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [("accounts", "0026_auto_20190204_2042")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="profile",
|
||||||
|
name="deletion_notice_date",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
)
|
||||||
|
]
|
23
hc/accounts/migrations/0028_auto_20191119_1346.py
Normal file
23
hc/accounts/migrations/0028_auto_20191119_1346.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 2.2.6 on 2019-11-19 13:46
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0027_profile_deletion_notice_date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='profile',
|
||||||
|
name='last_active_date',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='profile',
|
||||||
|
name='sms_limit',
|
||||||
|
field=models.IntegerField(default=5),
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 3.0.1 on 2020-03-02 07:56
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0028_auto_20191119_1346'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='profile',
|
||||||
|
name='current_project',
|
||||||
|
),
|
||||||
|
]
|
18
hc/accounts/migrations/0030_member_transfer_request_date.py
Normal file
18
hc/accounts/migrations/0030_member_transfer_request_date.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.0.4 on 2020-04-11 13:10
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0029_remove_profile_current_project'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='member',
|
||||||
|
name='transfer_request_date',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
28
hc/accounts/migrations/0031_auto_20200803_1413.py
Normal file
28
hc/accounts/migrations/0031_auto_20200803_1413.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 3.0.8 on 2020-08-03 14:13
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0030_member_transfer_request_date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='profile',
|
||||||
|
name='call_limit',
|
||||||
|
field=models.IntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='profile',
|
||||||
|
name='calls_sent',
|
||||||
|
field=models.IntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='profile',
|
||||||
|
name='last_call_date',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
17
hc/accounts/migrations/0032_auto_20200819_0757.py
Normal file
17
hc/accounts/migrations/0032_auto_20200819_0757.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 3.1 on 2020-08-19 07:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0031_auto_20200803_1413'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='member',
|
||||||
|
constraint=models.UniqueConstraint(fields=('user', 'project'), name='accounts_member_no_duplicates'),
|
||||||
|
),
|
||||||
|
]
|
18
hc/accounts/migrations/0033_member_rw.py
Normal file
18
hc/accounts/migrations/0033_member_rw.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.1 on 2020-08-24 16:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0032_auto_20200819_0757'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='member',
|
||||||
|
name='rw',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
]
|
28
hc/accounts/migrations/0034_credential.py
Normal file
28
hc/accounts/migrations/0034_credential.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 3.1.2 on 2020-11-14 09:29
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('accounts', '0033_member_rw'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Credential',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('code', models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('data', models.BinaryField()),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='credentials', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
18
hc/accounts/migrations/0035_profile_reports.py
Normal file
18
hc/accounts/migrations/0035_profile_reports.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.2 on 2021-05-24 07:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0034_credential'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='profile',
|
||||||
|
name='reports',
|
||||||
|
field=models.CharField(choices=[('off', 'Off'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], default='monthly', max_length=10),
|
||||||
|
),
|
||||||
|
]
|
18
hc/accounts/migrations/0036_fill_profile_reports.py
Normal file
18
hc/accounts/migrations/0036_fill_profile_reports.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.2 on 2021-05-24 07:38
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def fill_reports_field(apps, schema_editor):
|
||||||
|
Profile = apps.get_model("accounts", "Profile")
|
||||||
|
Profile.objects.filter(reports_allowed=False).update(reports="off")
|
||||||
|
Profile.objects.filter(reports_allowed=True).update(reports="monthly")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0035_profile_reports"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [migrations.RunPython(fill_reports_field, migrations.RunPython.noop)]
|
18
hc/accounts/migrations/0037_profile_tz.py
Normal file
18
hc/accounts/migrations/0037_profile_tz.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.2 on 2021-05-24 09:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0036_fill_profile_reports'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='profile',
|
||||||
|
name='tz',
|
||||||
|
field=models.CharField(default='UTC', max_length=36),
|
||||||
|
),
|
||||||
|
]
|
18
hc/accounts/migrations/0038_profile_theme.py
Normal file
18
hc/accounts/migrations/0038_profile_theme.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-06-18 09:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0037_profile_tz'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='profile',
|
||||||
|
name='theme',
|
||||||
|
field=models.CharField(blank=True, max_length=10, null=True),
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-06-29 11:35
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0038_profile_theme'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='profile',
|
||||||
|
name='reports_allowed',
|
||||||
|
),
|
||||||
|
]
|
23
hc/accounts/migrations/0040_auto_20210722_1244.py
Normal file
23
hc/accounts/migrations/0040_auto_20210722_1244.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-07-22 12:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0039_remove_profile_reports_allowed'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='member',
|
||||||
|
name='role',
|
||||||
|
field=models.CharField(choices=[('r', 'Read-only'), ('w', 'Member')], default='w', max_length=1),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='member',
|
||||||
|
name='rw',
|
||||||
|
field=models.BooleanField(default=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
20
hc/accounts/migrations/0041_fill_role.py
Normal file
20
hc/accounts/migrations/0041_fill_role.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-07-22 13:25
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def fill_member_role(apps, schema_editor):
|
||||||
|
Member = apps.get_model("accounts", "Member")
|
||||||
|
Member.objects.filter(rw=False).update(role="r")
|
||||||
|
Member.objects.filter(rw=True).update(role="w")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0040_auto_20210722_1244"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(fill_member_role, migrations.RunPython.noop),
|
||||||
|
]
|
17
hc/accounts/migrations/0042_remove_member_rw.py
Normal file
17
hc/accounts/migrations/0042_remove_member_rw.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-07-22 14:39
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0041_fill_role'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='member',
|
||||||
|
name='rw',
|
||||||
|
),
|
||||||
|
]
|
17
hc/accounts/migrations/0043_add_role_manager.py
Normal file
17
hc/accounts/migrations/0043_add_role_manager.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
from hc.accounts.models import Member
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0042_remove_member_rw'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='member',
|
||||||
|
name='role',
|
||||||
|
field=models.CharField(choices=Member.Role.choices, default=Member.Role.REGULAR, max_length=1),
|
||||||
|
),
|
||||||
|
]
|
23
hc/accounts/migrations/0044_auto_20210730_0942.py
Normal file
23
hc/accounts/migrations/0044_auto_20210730_0942.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-07-30 09:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0043_add_role_manager'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='profile',
|
||||||
|
name='totp',
|
||||||
|
field=models.CharField(blank=True, max_length=32, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='profile',
|
||||||
|
name='totp_created',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
@ -1,21 +1,30 @@
|
|||||||
from base64 import urlsafe_b64encode
|
|
||||||
import os
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
import random
|
||||||
|
from secrets import token_urlsafe
|
||||||
|
from urllib.parse import quote, urlencode
|
||||||
|
import uuid
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import check_password, make_password
|
from django.contrib.auth.hashers import check_password, make_password
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.signing import TimestampSigner
|
from django.core.signing import TimestampSigner
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Count, Q
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from fido2.ctap2 import AttestedCredentialData
|
||||||
from hc.lib import emails
|
from hc.lib import emails
|
||||||
|
from hc.lib.date import month_boundaries
|
||||||
|
import pytz
|
||||||
|
|
||||||
NO_NAG = timedelta()
|
NO_NAG = timedelta()
|
||||||
NAG_PERIODS = ((NO_NAG, "Disabled"),
|
NAG_PERIODS = (
|
||||||
|
(NO_NAG, "Disabled"),
|
||||||
(timedelta(hours=1), "Hourly"),
|
(timedelta(hours=1), "Hourly"),
|
||||||
(timedelta(days=1), "Daily"))
|
(timedelta(days=1), "Daily"),
|
||||||
|
)
|
||||||
|
|
||||||
|
REPORT_CHOICES = (("off", "Off"), ("weekly", "Weekly"), ("monthly", "Monthly"))
|
||||||
|
|
||||||
|
|
||||||
def month(dt):
|
def month(dt):
|
||||||
@ -33,6 +42,7 @@ class ProfileManager(models.Manager):
|
|||||||
# If not using payments, set high limits
|
# If not using payments, set high limits
|
||||||
profile.check_limit = 500
|
profile.check_limit = 500
|
||||||
profile.sms_limit = 500
|
profile.sms_limit = 500
|
||||||
|
profile.call_limit = 500
|
||||||
profile.team_limit = 500
|
profile.team_limit = 500
|
||||||
|
|
||||||
profile.save()
|
profile.save()
|
||||||
@ -40,29 +50,37 @@ class ProfileManager(models.Manager):
|
|||||||
|
|
||||||
|
|
||||||
class Profile(models.Model):
|
class Profile(models.Model):
|
||||||
# Owner:
|
|
||||||
user = models.OneToOneField(User, models.CASCADE, blank=True, null=True)
|
user = models.OneToOneField(User, models.CASCADE, blank=True, null=True)
|
||||||
team_name = models.CharField(max_length=200, blank=True)
|
|
||||||
next_report_date = models.DateTimeField(null=True, blank=True)
|
next_report_date = models.DateTimeField(null=True, blank=True)
|
||||||
reports_allowed = models.BooleanField(default=True)
|
reports = models.CharField(max_length=10, default="monthly", choices=REPORT_CHOICES)
|
||||||
nag_period = models.DurationField(default=NO_NAG, choices=NAG_PERIODS)
|
nag_period = models.DurationField(default=NO_NAG, choices=NAG_PERIODS)
|
||||||
next_nag_date = models.DateTimeField(null=True, blank=True)
|
next_nag_date = models.DateTimeField(null=True, blank=True)
|
||||||
ping_log_limit = models.IntegerField(default=100)
|
ping_log_limit = models.IntegerField(default=100)
|
||||||
check_limit = models.IntegerField(default=20)
|
check_limit = models.IntegerField(default=20)
|
||||||
token = models.CharField(max_length=128, blank=True)
|
token = models.CharField(max_length=128, blank=True)
|
||||||
api_key = models.CharField(max_length=128, blank=True)
|
|
||||||
current_team = models.ForeignKey("self", models.SET_NULL, null=True)
|
|
||||||
bill_to = models.TextField(blank=True)
|
|
||||||
last_sms_date = models.DateTimeField(null=True, blank=True)
|
last_sms_date = models.DateTimeField(null=True, blank=True)
|
||||||
sms_limit = models.IntegerField(default=0)
|
sms_limit = models.IntegerField(default=5)
|
||||||
sms_sent = models.IntegerField(default=0)
|
sms_sent = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
last_call_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
call_limit = models.IntegerField(default=0)
|
||||||
|
calls_sent = models.IntegerField(default=0)
|
||||||
|
|
||||||
team_limit = models.IntegerField(default=2)
|
team_limit = models.IntegerField(default=2)
|
||||||
sort = models.CharField(max_length=20, default="created")
|
sort = models.CharField(max_length=20, default="created")
|
||||||
|
deletion_notice_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
last_active_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
tz = models.CharField(max_length=36, default="UTC")
|
||||||
|
theme = models.CharField(max_length=10, null=True, blank=True)
|
||||||
|
|
||||||
|
totp = models.CharField(max_length=32, null=True, blank=True)
|
||||||
|
totp_created = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
objects = ProfileManager()
|
objects = ProfileManager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.team_name or self.user.email
|
return "Profile for %s" % self.user.email
|
||||||
|
|
||||||
def notifications_url(self):
|
def notifications_url(self):
|
||||||
return settings.SITE_ROOT + reverse("hc-notifications")
|
return settings.SITE_ROOT + reverse("hc-notifications")
|
||||||
@ -73,15 +91,8 @@ class Profile(models.Model):
|
|||||||
path = reverse("hc-unsubscribe-reports", args=[signed_username])
|
path = reverse("hc-unsubscribe-reports", args=[signed_username])
|
||||||
return settings.SITE_ROOT + path
|
return settings.SITE_ROOT + path
|
||||||
|
|
||||||
def team(self):
|
|
||||||
# compare ids to avoid SQL queries
|
|
||||||
if self.current_team_id and self.current_team_id != self.id:
|
|
||||||
return self.current_team
|
|
||||||
|
|
||||||
return self
|
|
||||||
|
|
||||||
def prepare_token(self, salt):
|
def prepare_token(self, salt):
|
||||||
token = urlsafe_b64encode(os.urandom(24)).decode("utf-8")
|
token = token_urlsafe(24)
|
||||||
self.token = make_password(token, salt)
|
self.token = make_password(token, salt)
|
||||||
self.save()
|
self.save()
|
||||||
return token
|
return token
|
||||||
@ -89,52 +100,86 @@ class Profile(models.Model):
|
|||||||
def check_token(self, token, salt):
|
def check_token(self, token, salt):
|
||||||
return salt in self.token and check_password(token, self.token)
|
return salt in self.token and check_password(token, self.token)
|
||||||
|
|
||||||
def send_instant_login_link(self, inviting_profile=None):
|
def send_instant_login_link(self, inviting_project=None, redirect_url=None):
|
||||||
token = self.prepare_token("login")
|
token = self.prepare_token("login")
|
||||||
path = reverse("hc-check-token", args=[self.user.username, token])
|
path = reverse("hc-check-token", args=[self.user.username, token])
|
||||||
|
if redirect_url:
|
||||||
|
path += "?next=%s" % redirect_url
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
"button_text": "Log In",
|
"button_text": "Sign In",
|
||||||
"button_url": settings.SITE_ROOT + path,
|
"button_url": settings.SITE_ROOT + path,
|
||||||
"inviting_profile": inviting_profile
|
"inviting_project": inviting_project,
|
||||||
}
|
}
|
||||||
emails.login(self.user.email, ctx)
|
emails.login(self.user.email, ctx)
|
||||||
|
|
||||||
def send_set_password_link(self):
|
def send_transfer_request(self, project):
|
||||||
token = self.prepare_token("set-password")
|
token = self.prepare_token("login")
|
||||||
path = reverse("hc-set-password", args=[token])
|
settings_path = reverse("hc-project-settings", args=[project.code])
|
||||||
|
path = reverse("hc-check-token", args=[self.user.username, token])
|
||||||
|
path += "?next=%s" % settings_path
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
"button_text": "Set Password",
|
"button_text": "Project Settings",
|
||||||
"button_url": settings.SITE_ROOT + path
|
"button_url": settings.SITE_ROOT + path,
|
||||||
|
"project": project,
|
||||||
}
|
}
|
||||||
emails.set_password(self.user.email, ctx)
|
emails.transfer_request(self.user.email, ctx)
|
||||||
|
|
||||||
def send_change_email_link(self):
|
def send_sms_limit_notice(self, transport):
|
||||||
token = self.prepare_token("change-email")
|
ctx = {"transport": transport, "limit": self.sms_limit}
|
||||||
path = reverse("hc-change-email", args=[token])
|
if self.sms_limit != 500 and settings.USE_PAYMENTS:
|
||||||
ctx = {
|
ctx["url"] = settings.SITE_ROOT + reverse("hc-pricing")
|
||||||
"button_text": "Change Email",
|
|
||||||
"button_url": settings.SITE_ROOT + path
|
|
||||||
}
|
|
||||||
emails.change_email(self.user.email, ctx)
|
|
||||||
|
|
||||||
def set_api_key(self):
|
emails.sms_limit(self.user.email, ctx)
|
||||||
self.api_key = urlsafe_b64encode(os.urandom(24))
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def checks_from_all_teams(self):
|
def send_call_limit_notice(self):
|
||||||
""" Return a queryset of checks from all teams we have access for. """
|
ctx = {"limit": self.call_limit}
|
||||||
|
if self.call_limit != 500 and settings.USE_PAYMENTS:
|
||||||
|
ctx["url"] = settings.SITE_ROOT + reverse("hc-pricing")
|
||||||
|
|
||||||
team_ids = set(self.user.memberships.values_list("team_id", flat=True))
|
emails.call_limit(self.user.email, ctx)
|
||||||
team_ids.add(self.id)
|
|
||||||
|
def projects(self):
|
||||||
|
""" Return a queryset of all projects we have access to. """
|
||||||
|
|
||||||
|
is_owner = Q(owner_id=self.user_id)
|
||||||
|
is_member = Q(member__user_id=self.user_id)
|
||||||
|
q = Project.objects.filter(is_owner | is_member)
|
||||||
|
return q.distinct().order_by("name")
|
||||||
|
|
||||||
|
def annotated_projects(self):
|
||||||
|
""" Return all projects, annotated with 'n_down'. """
|
||||||
|
|
||||||
|
# Subquery for getting project ids
|
||||||
|
project_ids = self.projects().values("id")
|
||||||
|
|
||||||
|
# Main query with the n_down annotation.
|
||||||
|
# Must use the subquery, otherwise ORM gets confused by
|
||||||
|
# joins and group by's
|
||||||
|
q = Project.objects.filter(id__in=project_ids)
|
||||||
|
n_down = Count("check", filter=Q(check__status="down"))
|
||||||
|
q = q.annotate(n_down=n_down)
|
||||||
|
return q.order_by("name")
|
||||||
|
|
||||||
|
def checks_from_all_projects(self):
|
||||||
|
""" Return a queryset of checks from projects we have access to. """
|
||||||
|
|
||||||
|
project_ids = self.projects().values("id")
|
||||||
|
|
||||||
from hc.api.models import Check
|
from hc.api.models import Check
|
||||||
return Check.objects.filter(user__profile__id__in=team_ids)
|
|
||||||
|
return Check.objects.filter(project_id__in=project_ids)
|
||||||
|
|
||||||
def send_report(self, nag=False):
|
def send_report(self, nag=False):
|
||||||
checks = self.checks_from_all_teams()
|
checks = self.checks_from_all_projects()
|
||||||
|
|
||||||
# Is there at least one check that has received a ping?
|
# Has there been a ping in last 6 months?
|
||||||
if not checks.filter(last_ping__isnull=False).exists():
|
result = checks.aggregate(models.Max("last_ping"))
|
||||||
|
last_ping = result["last_ping__max"]
|
||||||
|
|
||||||
|
six_months_ago = timezone.now() - timedelta(days=180)
|
||||||
|
if last_ping is None or last_ping < six_months_ago:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Is there at least one check that is down?
|
# Is there at least one check that is down?
|
||||||
@ -142,42 +187,42 @@ class Profile(models.Model):
|
|||||||
if nag and num_down == 0:
|
if nag and num_down == 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Sort checks by owner. Need this because will group by owner in
|
# Sort checks by project. Need this because will group by project in
|
||||||
# template.
|
# template.
|
||||||
checks = checks.select_related("user", "user__profile")
|
checks = checks.select_related("project")
|
||||||
checks = checks.order_by("user_id")
|
checks = checks.order_by("project_id")
|
||||||
# list() executes the query, to avoid DB access while
|
# list() executes the query, to avoid DB access while
|
||||||
# rendering the template
|
# rendering the template
|
||||||
checks = list(checks)
|
checks = list(checks)
|
||||||
|
|
||||||
|
unsub_url = self.reports_unsub_url()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"List-Unsubscribe": "<%s>" % unsub_url,
|
||||||
|
"X-Bounce-Url": unsub_url,
|
||||||
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||||
|
}
|
||||||
|
|
||||||
|
boundaries = month_boundaries(months=3)
|
||||||
|
# throw away the current month, keep two previous months
|
||||||
|
boundaries.pop()
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
"checks": checks,
|
"checks": checks,
|
||||||
"sort": self.sort,
|
"sort": self.sort,
|
||||||
"now": timezone.now(),
|
"now": timezone.now(),
|
||||||
"unsub_link": self.reports_unsub_url(),
|
"unsub_link": unsub_url,
|
||||||
"notifications_url": self.notifications_url(),
|
"notifications_url": self.notifications_url(),
|
||||||
"nag": nag,
|
"nag": nag,
|
||||||
"nag_period": self.nag_period.total_seconds(),
|
"nag_period": self.nag_period.total_seconds(),
|
||||||
"num_down": num_down
|
"num_down": num_down,
|
||||||
|
"month_boundaries": boundaries,
|
||||||
|
"monthly_or_weekly": self.reports,
|
||||||
}
|
}
|
||||||
|
|
||||||
emails.report(self.user.email, ctx)
|
emails.report(self.user.email, ctx, headers)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def can_invite(self):
|
|
||||||
return self.member_set.count() < self.team_limit
|
|
||||||
|
|
||||||
def invite(self, user):
|
|
||||||
member = Member(team=self, user=user)
|
|
||||||
member.save()
|
|
||||||
|
|
||||||
# Switch the invited user over to the new team so they
|
|
||||||
# notice the new team on next visit:
|
|
||||||
user.profile.current_team = self
|
|
||||||
user.profile.save()
|
|
||||||
|
|
||||||
user.profile.send_instant_login_link(self)
|
|
||||||
|
|
||||||
def sms_sent_this_month(self):
|
def sms_sent_this_month(self):
|
||||||
# IF last_sms_date was never set, we have not sent any messages yet.
|
# IF last_sms_date was never set, we have not sent any messages yet.
|
||||||
if not self.last_sms_date:
|
if not self.last_sms_date:
|
||||||
@ -201,19 +246,211 @@ class Profile(models.Model):
|
|||||||
self.save()
|
self.save()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def set_next_nag_date(self):
|
def calls_sent_this_month(self):
|
||||||
""" Set next_nag_date for all members of this team. """
|
# IF last_call_date was never set, we have not made any phone calls yet.
|
||||||
|
if not self.last_call_date:
|
||||||
|
return 0
|
||||||
|
|
||||||
is_owner = models.Q(id=self.id)
|
# If last sent date is not from this month, we've made 0 calls this month.
|
||||||
is_member = models.Q(user__memberships__team=self)
|
if month(timezone.now()) > month(self.last_call_date):
|
||||||
q = Profile.objects.filter(is_owner | is_member)
|
return 0
|
||||||
q = q.exclude(nag_period=NO_NAG)
|
|
||||||
# Exclude profiles with next_nag_date already set
|
|
||||||
q = q.filter(next_nag_date__isnull=True)
|
|
||||||
|
|
||||||
q.update(next_nag_date=timezone.now() + models.F("nag_period"))
|
return self.calls_sent
|
||||||
|
|
||||||
|
def authorize_call(self):
|
||||||
|
""" If monthly limit not exceeded, increase counter and return True """
|
||||||
|
|
||||||
|
sent_this_month = self.calls_sent_this_month()
|
||||||
|
if sent_this_month >= self.call_limit:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.calls_sent = sent_this_month + 1
|
||||||
|
self.last_call_date = timezone.now()
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def num_checks_used(self):
|
||||||
|
from hc.api.models import Check
|
||||||
|
|
||||||
|
return Check.objects.filter(project__owner_id=self.user_id).count()
|
||||||
|
|
||||||
|
def num_checks_available(self):
|
||||||
|
return self.check_limit - self.num_checks_used()
|
||||||
|
|
||||||
|
def can_accept(self, project):
|
||||||
|
return project.num_checks() <= self.num_checks_available()
|
||||||
|
|
||||||
|
def update_next_nag_date(self):
|
||||||
|
any_down = self.checks_from_all_projects().filter(status="down").exists()
|
||||||
|
if any_down and self.next_nag_date is None and self.nag_period:
|
||||||
|
self.next_nag_date = timezone.now() + self.nag_period
|
||||||
|
self.save(update_fields=["next_nag_date"])
|
||||||
|
elif not any_down and self.next_nag_date:
|
||||||
|
self.next_nag_date = None
|
||||||
|
self.save(update_fields=["next_nag_date"])
|
||||||
|
|
||||||
|
def choose_next_report_date(self):
|
||||||
|
""" Calculate the target date for the next monthly/weekly report.
|
||||||
|
|
||||||
|
Monthly reports should get sent on 1st of each month, between
|
||||||
|
9AM and 11AM in user's timezone.
|
||||||
|
|
||||||
|
Weekly reports should get sent on Mondays, between
|
||||||
|
9AM and 11AM in user's timezone.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.reports == "off":
|
||||||
|
return None
|
||||||
|
|
||||||
|
tz = pytz.timezone(self.tz)
|
||||||
|
dt = timezone.now().astimezone(tz)
|
||||||
|
dt = dt.replace(hour=9, minute=0) + timedelta(minutes=random.randrange(0, 120))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
dt += timedelta(days=1)
|
||||||
|
if self.reports == "monthly" and dt.day == 1:
|
||||||
|
return dt
|
||||||
|
elif self.reports == "weekly" and dt.weekday() == 0:
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
class Project(models.Model):
|
||||||
|
code = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||||
|
name = models.CharField(max_length=200, blank=True)
|
||||||
|
owner = models.ForeignKey(User, models.CASCADE)
|
||||||
|
api_key = models.CharField(max_length=128, blank=True, db_index=True)
|
||||||
|
api_key_readonly = models.CharField(max_length=128, blank=True, db_index=True)
|
||||||
|
badge_key = models.CharField(max_length=150, unique=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name or self.owner.email
|
||||||
|
|
||||||
|
@property
|
||||||
|
def owner_profile(self):
|
||||||
|
return Profile.objects.for_user(self.owner)
|
||||||
|
|
||||||
|
def num_checks(self):
|
||||||
|
return self.check_set.count()
|
||||||
|
|
||||||
|
def num_checks_available(self):
|
||||||
|
return self.owner_profile.num_checks_available()
|
||||||
|
|
||||||
|
def set_api_keys(self):
|
||||||
|
self.api_key = token_urlsafe(nbytes=24)
|
||||||
|
self.api_key_readonly = token_urlsafe(nbytes=24)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def invite_suggestions(self):
|
||||||
|
q = User.objects.filter(memberships__project__owner_id=self.owner_id)
|
||||||
|
q = q.exclude(memberships__project=self)
|
||||||
|
return q.distinct().order_by("email")
|
||||||
|
|
||||||
|
def can_invite_new_users(self):
|
||||||
|
q = User.objects.filter(memberships__project__owner_id=self.owner_id)
|
||||||
|
used = q.distinct().count()
|
||||||
|
return used < self.owner_profile.team_limit
|
||||||
|
|
||||||
|
def invite(self, user, role):
|
||||||
|
if Member.objects.filter(user=user, project=self).exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.owner_id == user.id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
Member.objects.create(user=user, project=self, role=role)
|
||||||
|
checks_url = reverse("hc-checks", args=[self.code])
|
||||||
|
user.profile.send_instant_login_link(self, redirect_url=checks_url)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_next_nag_dates(self):
|
||||||
|
""" Update next_nag_date on profiles of all members of this project. """
|
||||||
|
|
||||||
|
is_owner = Q(user_id=self.owner_id)
|
||||||
|
is_member = Q(user__memberships__project=self)
|
||||||
|
q = Profile.objects.filter(is_owner | is_member).exclude(nag_period=NO_NAG)
|
||||||
|
|
||||||
|
for profile in q:
|
||||||
|
profile.update_next_nag_date()
|
||||||
|
|
||||||
|
def overall_status(self):
|
||||||
|
if not hasattr(self, "_overall_status"):
|
||||||
|
self._overall_status = "up"
|
||||||
|
for check in self.check_set.all():
|
||||||
|
check_status = check.get_status()
|
||||||
|
if check_status == "grace" and self._overall_status == "up":
|
||||||
|
self._overall_status = "grace"
|
||||||
|
elif check_status == "down":
|
||||||
|
self._overall_status = "down"
|
||||||
|
break
|
||||||
|
|
||||||
|
return self._overall_status
|
||||||
|
|
||||||
|
def get_n_down(self):
|
||||||
|
result = 0
|
||||||
|
for check in self.check_set.all():
|
||||||
|
if check.get_status() == "down":
|
||||||
|
result += 1
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def have_channel_issues(self):
|
||||||
|
errors = list(self.channel_set.values_list("last_error", flat=True))
|
||||||
|
|
||||||
|
# It's a problem if a project has no integrations at all
|
||||||
|
if len(errors) == 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# It's a problem if any integration has a logged error
|
||||||
|
return True if max(errors) else False
|
||||||
|
|
||||||
|
def transfer_request(self):
|
||||||
|
return self.member_set.filter(transfer_request_date__isnull=False).first()
|
||||||
|
|
||||||
|
def dashboard_url(self):
|
||||||
|
if not self.api_key_readonly:
|
||||||
|
return None
|
||||||
|
|
||||||
|
frag = urlencode({self.api_key_readonly: str(self)}, quote_via=quote)
|
||||||
|
return reverse("hc-dashboard") + "#" + frag
|
||||||
|
|
||||||
|
def checks_url(self):
|
||||||
|
return settings.SITE_ROOT + reverse("hc-checks", args=[self.code])
|
||||||
|
|
||||||
|
|
||||||
class Member(models.Model):
|
class Member(models.Model):
|
||||||
team = models.ForeignKey(Profile, models.CASCADE)
|
class Role(models.TextChoices):
|
||||||
|
READONLY = "r", "Read-only"
|
||||||
|
REGULAR = "w", "Member"
|
||||||
|
MANAGER = "m", "Manager"
|
||||||
|
|
||||||
user = models.ForeignKey(User, models.CASCADE, related_name="memberships")
|
user = models.ForeignKey(User, models.CASCADE, related_name="memberships")
|
||||||
|
project = models.ForeignKey(Project, models.CASCADE)
|
||||||
|
transfer_request_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
role = models.CharField(max_length=1, default=Role.REGULAR, choices=Role.choices)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "project"], name="accounts_member_no_duplicates"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def can_accept(self):
|
||||||
|
return self.user.profile.can_accept(self.project)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_rw(self):
|
||||||
|
return self.role in (Member.Role.REGULAR, Member.Role.MANAGER)
|
||||||
|
|
||||||
|
|
||||||
|
class Credential(models.Model):
|
||||||
|
code = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
user = models.ForeignKey(User, models.CASCADE, related_name="credentials")
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
data = models.BinaryField()
|
||||||
|
|
||||||
|
def unpack(self):
|
||||||
|
unpacked, remaining_data = AttestedCredentialData.unpack_from(self.data)
|
||||||
|
return unpacked
|
||||||
|
17
hc/accounts/tests/test_add_project.py
Normal file
17
hc/accounts/tests/test_add_project.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from hc.accounts.models import Project
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class AddProjectTestCase(BaseTestCase):
|
||||||
|
def test_it_works(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.post("/projects/add/", {"name": "My Second Project"})
|
||||||
|
|
||||||
|
p = Project.objects.get(owner=self.alice, name="My Second Project")
|
||||||
|
self.assertRedirects(r, "/projects/%s/checks/" % p.code)
|
||||||
|
self.assertEqual(str(p.code), p.badge_key)
|
||||||
|
|
||||||
|
def test_it_rejects_get(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.get("/projects/add/")
|
||||||
|
self.assertEqual(r.status_code, 405)
|
83
hc/accounts/tests/test_add_totp.py
Normal file
83
hc/accounts/tests/test_add_totp.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class AddTotpTestCase(BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.url = "/accounts/two_factor/totp/"
|
||||||
|
|
||||||
|
def test_it_requires_sudo_mode(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "We have sent a confirmation code")
|
||||||
|
|
||||||
|
def test_it_shows_form(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "Enter the six-digit code")
|
||||||
|
|
||||||
|
# It should put a "totp_secret" key in the session:
|
||||||
|
self.assertIn("totp_secret", self.client.session)
|
||||||
|
|
||||||
|
@patch("hc.accounts.views.pyotp.totp.TOTP")
|
||||||
|
def test_it_adds_totp(self, mock_TOTP):
|
||||||
|
mock_TOTP.return_value.verify.return_value = True
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
payload = {"code": "000000"}
|
||||||
|
r = self.client.post(self.url, payload, follow=True)
|
||||||
|
self.assertRedirects(r, "/accounts/profile/")
|
||||||
|
self.assertContains(r, "Successfully set up the Authenticator app")
|
||||||
|
|
||||||
|
# totp_secret should be gone from the session:
|
||||||
|
self.assertNotIn("totp_secret", self.client.session)
|
||||||
|
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
self.assertTrue(self.profile.totp)
|
||||||
|
self.assertTrue(self.profile.totp_created)
|
||||||
|
|
||||||
|
@patch("hc.accounts.views.pyotp.totp.TOTP")
|
||||||
|
def test_it_handles_wrong_code(self, mock_TOTP):
|
||||||
|
mock_TOTP.return_value.verify.return_value = False
|
||||||
|
mock_TOTP.return_value.provisioning_uri.return_value = "test-uri"
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
payload = {"code": "000000"}
|
||||||
|
r = self.client.post(self.url, payload, follow=True)
|
||||||
|
self.assertContains(r, "The code you entered was incorrect.")
|
||||||
|
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
self.assertIsNone(self.profile.totp)
|
||||||
|
self.assertIsNone(self.profile.totp_created)
|
||||||
|
|
||||||
|
def test_it_checks_if_totp_already_configured(self):
|
||||||
|
self.profile.totp = "0" * 32
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
@patch("hc.accounts.views.pyotp.totp.TOTP")
|
||||||
|
def test_it_handles_non_numeric_code(self, mock_TOTP):
|
||||||
|
mock_TOTP.return_value.verify.return_value = False
|
||||||
|
mock_TOTP.return_value.provisioning_uri.return_value = "test-uri"
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
payload = {"code": "AAAAAA"}
|
||||||
|
r = self.client.post(self.url, payload, follow=True)
|
||||||
|
self.assertContains(r, "Enter a valid value")
|
98
hc/accounts/tests/test_add_webauthn.py
Normal file
98
hc/accounts/tests/test_add_webauthn.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
from hc.accounts.models import Credential
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(RP_ID="testserver")
|
||||||
|
class AddWebauthnTestCase(BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.url = "/accounts/two_factor/webauthn/"
|
||||||
|
|
||||||
|
def test_it_requires_sudo_mode(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "We have sent a confirmation code")
|
||||||
|
|
||||||
|
@override_settings(RP_ID=None)
|
||||||
|
def test_it_requires_rp_id(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
def test_it_shows_form(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "Add Security Key")
|
||||||
|
|
||||||
|
# It should put a "state" key in the session:
|
||||||
|
self.assertIn("state", self.client.session)
|
||||||
|
|
||||||
|
@patch("hc.accounts.views._get_credential_data")
|
||||||
|
def test_it_adds_credential(self, mock_get_credential_data):
|
||||||
|
mock_get_credential_data.return_value = b"dummy-credential-data"
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": "My New Key",
|
||||||
|
"client_data_json": "e30=",
|
||||||
|
"attestation_object": "e30=",
|
||||||
|
}
|
||||||
|
|
||||||
|
r = self.client.post(self.url, payload, follow=True)
|
||||||
|
self.assertRedirects(r, "/accounts/profile/")
|
||||||
|
self.assertContains(r, "Added security key <strong>My New Key</strong>")
|
||||||
|
|
||||||
|
c = Credential.objects.get()
|
||||||
|
self.assertEqual(c.name, "My New Key")
|
||||||
|
|
||||||
|
def test_it_rejects_bad_base64(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": "My New Key",
|
||||||
|
"client_data_json": "not valid base64",
|
||||||
|
"attestation_object": "not valid base64",
|
||||||
|
}
|
||||||
|
|
||||||
|
r = self.client.post(self.url, payload)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
def test_it_requires_client_data_json(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": "My New Key",
|
||||||
|
"attestation_object": "e30=",
|
||||||
|
}
|
||||||
|
|
||||||
|
r = self.client.post(self.url, payload)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
@patch("hc.accounts.views._get_credential_data")
|
||||||
|
def test_it_handles_authentication_failure(self, mock_get_credential_data):
|
||||||
|
mock_get_credential_data.return_value = None
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": "My New Key",
|
||||||
|
"client_data_json": "e30=",
|
||||||
|
"attestation_object": "e30=",
|
||||||
|
}
|
||||||
|
|
||||||
|
r = self.client.post(self.url, payload, follow=True)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
@ -2,9 +2,8 @@ from hc.test import BaseTestCase
|
|||||||
|
|
||||||
|
|
||||||
class AccountsAdminTestCase(BaseTestCase):
|
class AccountsAdminTestCase(BaseTestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(AccountsAdminTestCase, self).setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.alice.is_staff = True
|
self.alice.is_staff = True
|
||||||
self.alice.is_superuser = True
|
self.alice.is_superuser = True
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
from hc.test import BaseTestCase
|
|
||||||
from hc.api.models import Check
|
|
||||||
|
|
||||||
|
|
||||||
class BadgesTestCase(BaseTestCase):
|
|
||||||
|
|
||||||
def test_it_shows_badges(self):
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
|
||||||
Check.objects.create(user=self.alice, tags="foo a-B_1 baz@")
|
|
||||||
Check.objects.create(user=self.bob, tags="bobs-tag")
|
|
||||||
|
|
||||||
r = self.client.get("/accounts/profile/badges/")
|
|
||||||
self.assertContains(r, "foo.svg")
|
|
||||||
self.assertContains(r, "a-B_1.svg")
|
|
||||||
|
|
||||||
# Expect badge URLs only for tags that match \w+
|
|
||||||
self.assertNotContains(r, "baz@.svg")
|
|
||||||
|
|
||||||
# Expect only Alice's tags
|
|
||||||
self.assertNotContains(r, "bobs-tag.svg")
|
|
@ -1,41 +1,43 @@
|
|||||||
from django.contrib.auth.hashers import make_password
|
|
||||||
|
|
||||||
from hc.test import BaseTestCase
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
class ChangeEmailTestCase(BaseTestCase):
|
class ChangeEmailTestCase(BaseTestCase):
|
||||||
|
def test_it_requires_sudo_mode(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get("/accounts/change_email/")
|
||||||
|
self.assertContains(r, "We have sent a confirmation code")
|
||||||
|
|
||||||
def test_it_shows_form(self):
|
def test_it_shows_form(self):
|
||||||
self.profile.token = make_password("foo", "change-email")
|
|
||||||
self.profile.save()
|
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
r = self.client.get("/accounts/change_email/foo/")
|
r = self.client.get("/accounts/change_email/")
|
||||||
self.assertContains(r, "Change Account's Email Address")
|
self.assertContains(r, "Change Account's Email Address")
|
||||||
|
|
||||||
def test_it_changes_password(self):
|
def test_it_updates_email(self):
|
||||||
self.profile.token = make_password("foo", "change-email")
|
|
||||||
self.profile.save()
|
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
payload = {"email": "alice2@example.org"}
|
payload = {"email": "alice2@example.org"}
|
||||||
self.client.post("/accounts/change_email/foo/", payload)
|
r = self.client.post("/accounts/change_email/", payload, follow=True)
|
||||||
|
self.assertRedirects(r, "/accounts/change_email/done/")
|
||||||
|
self.assertContains(r, "Email Address Updated")
|
||||||
|
|
||||||
self.alice.refresh_from_db()
|
self.alice.refresh_from_db()
|
||||||
self.assertEqual(self.alice.email, "alice2@example.org")
|
self.assertEqual(self.alice.email, "alice2@example.org")
|
||||||
self.assertFalse(self.alice.has_usable_password())
|
self.assertFalse(self.alice.has_usable_password())
|
||||||
|
|
||||||
def test_it_requires_unique_email(self):
|
# The user should have been logged out:
|
||||||
self.profile.token = make_password("foo", "change-email")
|
self.assertNotIn("_auth_user_id", self.client.session)
|
||||||
self.profile.save()
|
|
||||||
|
|
||||||
|
def test_it_requires_unique_email(self):
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
payload = {"email": "bob@example.org"}
|
payload = {"email": "bob@example.org"}
|
||||||
r = self.client.post("/accounts/change_email/foo/", payload)
|
r = self.client.post("/accounts/change_email/", payload)
|
||||||
self.assertContains(r, "bob@example.org is not available")
|
self.assertContains(r, "bob@example.org is already registered")
|
||||||
|
|
||||||
self.alice.refresh_from_db()
|
self.alice.refresh_from_db()
|
||||||
self.assertEqual(self.alice.email, "alice@example.org")
|
self.assertEqual(self.alice.email, "alice@example.org")
|
||||||
|
@ -1,21 +1,24 @@
|
|||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
|
from hc.accounts.models import Credential
|
||||||
from hc.test import BaseTestCase
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
class CheckTokenTestCase(BaseTestCase):
|
class CheckTokenTestCase(BaseTestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(CheckTokenTestCase, self).setUp()
|
super().setUp()
|
||||||
self.profile.token = make_password("secret-token", "login")
|
self.profile.token = make_password("secret-token", "login")
|
||||||
self.profile.save()
|
self.profile.save()
|
||||||
|
|
||||||
|
self.checks_url = "/projects/%s/checks/" % self.project.code
|
||||||
|
|
||||||
def test_it_shows_form(self):
|
def test_it_shows_form(self):
|
||||||
r = self.client.get("/accounts/check_token/alice/secret-token/")
|
r = self.client.get("/accounts/check_token/alice/secret-token/")
|
||||||
self.assertContains(r, "You are about to log in")
|
self.assertContains(r, "You are about to log in")
|
||||||
|
|
||||||
def test_it_redirects(self):
|
def test_it_redirects(self):
|
||||||
r = self.client.post("/accounts/check_token/alice/secret-token/")
|
r = self.client.post("/accounts/check_token/alice/secret-token/")
|
||||||
self.assertRedirects(r, "/checks/")
|
|
||||||
|
self.assertRedirects(r, self.checks_url)
|
||||||
|
|
||||||
# After login, token should be blank
|
# After login, token should be blank
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
@ -27,7 +30,8 @@ class CheckTokenTestCase(BaseTestCase):
|
|||||||
|
|
||||||
# Login again, when already authenticated
|
# Login again, when already authenticated
|
||||||
r = self.client.post("/accounts/check_token/alice/secret-token/")
|
r = self.client.post("/accounts/check_token/alice/secret-token/")
|
||||||
self.assertRedirects(r, "/checks/")
|
|
||||||
|
self.assertRedirects(r, self.checks_url)
|
||||||
|
|
||||||
def test_it_redirects_bad_login(self):
|
def test_it_redirects_bad_login(self):
|
||||||
# Login with a bad token
|
# Login with a bad token
|
||||||
@ -35,3 +39,28 @@ class CheckTokenTestCase(BaseTestCase):
|
|||||||
r = self.client.post(url, follow=True)
|
r = self.client.post(url, follow=True)
|
||||||
self.assertRedirects(r, "/accounts/login/")
|
self.assertRedirects(r, "/accounts/login/")
|
||||||
self.assertContains(r, "incorrect or expired")
|
self.assertContains(r, "incorrect or expired")
|
||||||
|
|
||||||
|
def test_it_handles_next_parameter(self):
|
||||||
|
url = "/accounts/check_token/alice/secret-token/?next=" + self.channels_url
|
||||||
|
r = self.client.post(url)
|
||||||
|
self.assertRedirects(r, self.channels_url)
|
||||||
|
|
||||||
|
def test_it_ignores_bad_next_parameter(self):
|
||||||
|
url = "/accounts/check_token/alice/secret-token/?next=/evil/"
|
||||||
|
r = self.client.post(url)
|
||||||
|
self.assertRedirects(r, self.checks_url)
|
||||||
|
|
||||||
|
def test_it_redirects_to_webauthn_form(self):
|
||||||
|
Credential.objects.create(user=self.alice, name="Alices Key")
|
||||||
|
|
||||||
|
r = self.client.post("/accounts/check_token/alice/secret-token/")
|
||||||
|
self.assertRedirects(
|
||||||
|
r, "/accounts/login/two_factor/", fetch_redirect_response=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# It should not log the user in yet
|
||||||
|
self.assertNotIn("_auth_user_id", self.client.session)
|
||||||
|
|
||||||
|
# Instead, it should set 2fa_user_id in the session
|
||||||
|
user_id, email, valid_until = self.client.session["2fa_user"]
|
||||||
|
self.assertEqual(user_id, self.alice.id)
|
||||||
|
@ -1,57 +1,79 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from hc.api.models import Check
|
from hc.api.models import Check
|
||||||
from hc.payments.models import Subscription
|
from hc.payments.models import Subscription
|
||||||
from hc.test import BaseTestCase
|
from hc.test import BaseTestCase
|
||||||
from mock import patch
|
|
||||||
|
|
||||||
|
|
||||||
class CloseAccountTestCase(BaseTestCase):
|
class CloseAccountTestCase(BaseTestCase):
|
||||||
|
def test_it_requires_sudo_mode(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
@patch("hc.payments.models.Subscription.cancel")
|
r = self.client.get("/accounts/close/")
|
||||||
def test_it_works(self, mock_cancel):
|
self.assertContains(r, "We have sent a confirmation code")
|
||||||
Check.objects.create(user=self.alice, tags="foo a-B_1 baz@")
|
|
||||||
Subscription.objects.create(user=self.alice, subscription_id="123")
|
def test_it_shows_confirmation_form(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
r = self.client.get("/accounts/close/")
|
||||||
|
self.assertContains(r, "Close Account?")
|
||||||
|
self.assertContains(r, "1 project")
|
||||||
|
self.assertContains(r, "0 checks")
|
||||||
|
|
||||||
|
@patch("hc.payments.models.braintree")
|
||||||
|
def test_it_works(self, mock_braintree):
|
||||||
|
Check.objects.create(project=self.project, tags="foo a-B_1 baz@")
|
||||||
|
Subscription.objects.create(
|
||||||
|
user=self.alice, subscription_id="123", customer_id="fake-customer-id"
|
||||||
|
)
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
r = self.client.post("/accounts/close/")
|
self.set_sudo_flag()
|
||||||
self.assertEqual(r.status_code, 302)
|
|
||||||
|
payload = {"confirmation": "alice@example.org"}
|
||||||
|
r = self.client.post("/accounts/close/", payload)
|
||||||
|
self.assertRedirects(r, "/")
|
||||||
|
|
||||||
# Alice should be gone
|
# Alice should be gone
|
||||||
alices = User.objects.filter(username="alice")
|
alices = User.objects.filter(username="alice")
|
||||||
self.assertFalse(alices.exists())
|
self.assertFalse(alices.exists())
|
||||||
|
|
||||||
# Alice should be gone
|
|
||||||
alices = User.objects.filter(username="alice")
|
|
||||||
self.assertFalse(alices.exists())
|
|
||||||
|
|
||||||
# Bob's current team should now be None
|
|
||||||
self.bobs_profile.refresh_from_db()
|
|
||||||
self.assertIsNone(self.bobs_profile.current_team)
|
|
||||||
|
|
||||||
# Check should be gone
|
# Check should be gone
|
||||||
self.assertFalse(Check.objects.exists())
|
self.assertFalse(Check.objects.exists())
|
||||||
|
|
||||||
# Subscription should have been canceled
|
# Subscription should have been canceled
|
||||||
self.assertTrue(mock_cancel.called)
|
self.assertTrue(mock_braintree.Subscription.cancel.called)
|
||||||
|
|
||||||
# Subscription should be gone
|
# Subscription should be gone
|
||||||
self.assertFalse(Subscription.objects.exists())
|
self.assertFalse(Subscription.objects.exists())
|
||||||
|
|
||||||
def test_partner_removal_works(self):
|
def test_it_requires_confirmation(self):
|
||||||
self.client.login(username="bob@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
r = self.client.post("/accounts/close/")
|
self.set_sudo_flag()
|
||||||
self.assertEqual(r.status_code, 302)
|
|
||||||
|
payload = {"confirmation": "incorrect"}
|
||||||
|
r = self.client.post("/accounts/close/", payload)
|
||||||
|
self.assertContains(r, "Close Account?")
|
||||||
|
self.assertContains(r, "has-error")
|
||||||
|
|
||||||
|
# Alice should be still present
|
||||||
|
self.alice.refresh_from_db()
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
|
||||||
|
def test_partner_removal_works(self):
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
payload = {"confirmation": "bob@example.org"}
|
||||||
|
r = self.client.post("/accounts/close/", payload)
|
||||||
|
self.assertRedirects(r, "/")
|
||||||
|
|
||||||
# Alice should be still present
|
# Alice should be still present
|
||||||
self.alice.refresh_from_db()
|
self.alice.refresh_from_db()
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
self.assertEqual(self.profile.current_team, None)
|
|
||||||
|
|
||||||
# Bob should be gone
|
# Bob should be gone
|
||||||
bobs = User.objects.filter(username="bob")
|
bobs = User.objects.filter(username="bob")
|
||||||
self.assertFalse(bobs.exists())
|
self.assertFalse(bobs.exists())
|
||||||
|
|
||||||
def test_it_rejects_get(self):
|
|
||||||
self.client.login(username="bob@example.org", password="password")
|
|
||||||
r = self.client.get("/accounts/close/")
|
|
||||||
self.assertEqual(r.status_code, 405)
|
|
||||||
|
@ -1,84 +1,163 @@
|
|||||||
from django.contrib.auth.models import User
|
|
||||||
from django.core import mail
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.test.utils import override_settings
|
|
||||||
from hc.accounts.models import Profile
|
|
||||||
from hc.api.models import Check
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core import mail
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
from hc.accounts.models import Credential
|
||||||
|
from hc.api.models import Check, TokenBucket
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
class LoginTestCase(TestCase):
|
class LoginTestCase(BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.checks_url = f"/projects/{self.project.code}/checks/"
|
||||||
|
|
||||||
|
def test_it_shows_form(self):
|
||||||
|
r = self.client.get("/accounts/login/")
|
||||||
|
self.assertContains(r, "Email Me a Link")
|
||||||
|
|
||||||
|
def test_it_redirects_authenticated_get(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get("/accounts/login/")
|
||||||
|
self.assertRedirects(r, self.checks_url)
|
||||||
|
|
||||||
def test_it_sends_link(self):
|
def test_it_sends_link(self):
|
||||||
check = Check()
|
form = {"identity": "alice@example.org"}
|
||||||
check.save()
|
|
||||||
|
|
||||||
session = self.client.session
|
|
||||||
session["welcome_code"] = str(check.code)
|
|
||||||
session.save()
|
|
||||||
|
|
||||||
form = {"email": "alice@example.org"}
|
|
||||||
|
|
||||||
r = self.client.post("/accounts/login/", form)
|
r = self.client.post("/accounts/login/", form)
|
||||||
assert r.status_code == 302
|
self.assertRedirects(r, "/accounts/login_link_sent/")
|
||||||
|
|
||||||
# An user should have been created
|
# And email should have been sent
|
||||||
self.assertEqual(User.objects.count(), 1)
|
|
||||||
|
|
||||||
# And email sent
|
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
subject = "Log in to %s" % settings.SITE_NAME
|
subject = "Log in to %s" % settings.SITE_NAME
|
||||||
self.assertEqual(mail.outbox[0].subject, subject)
|
self.assertEqual(mail.outbox[0].subject, subject)
|
||||||
|
|
||||||
# And check should be associated with the new user
|
def test_it_sends_link_with_next(self):
|
||||||
check_again = Check.objects.get(code=check.code)
|
form = {"identity": "alice@example.org"}
|
||||||
assert check_again.user
|
|
||||||
|
r = self.client.post("/accounts/login/?next=" + self.channels_url, form)
|
||||||
|
self.assertRedirects(r, "/accounts/login_link_sent/")
|
||||||
|
self.assertIn("auto-login", r.cookies)
|
||||||
|
|
||||||
|
# The check_token link should have a ?next= query parameter:
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
body = mail.outbox[0].body
|
||||||
|
self.assertTrue("/?next=" + self.channels_url in body)
|
||||||
|
|
||||||
|
@override_settings(SECRET_KEY="test-secret")
|
||||||
|
def test_it_rate_limits_emails(self):
|
||||||
|
# "d60d..." is sha1("alice@example.orgtest-secret")
|
||||||
|
obj = TokenBucket(value="em-d60db3b2343e713a4de3e92d4eb417e4f05f06ab")
|
||||||
|
obj.tokens = 0
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
form = {"identity": "alice@example.org"}
|
||||||
|
|
||||||
|
r = self.client.post("/accounts/login/", form)
|
||||||
|
self.assertContains(r, "Too many attempts")
|
||||||
|
|
||||||
|
# No email should have been sent
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
def test_it_pops_bad_link_from_session(self):
|
def test_it_pops_bad_link_from_session(self):
|
||||||
self.client.session["bad_link"] = True
|
self.client.session["bad_link"] = True
|
||||||
self.client.get("/accounts/login/")
|
self.client.get("/accounts/login/")
|
||||||
assert "bad_link" not in self.client.session
|
assert "bad_link" not in self.client.session
|
||||||
|
|
||||||
def test_it_handles_missing_welcome_check(self):
|
def test_it_ignores_case(self):
|
||||||
|
form = {"identity": "ALICE@EXAMPLE.ORG"}
|
||||||
# This check does not exist in database,
|
|
||||||
# but login should still work.
|
|
||||||
session = self.client.session
|
|
||||||
session["welcome_code"] = "00000000-0000-0000-0000-000000000000"
|
|
||||||
session.save()
|
|
||||||
|
|
||||||
form = {"email": "alice@example.org"}
|
|
||||||
|
|
||||||
r = self.client.post("/accounts/login/", form)
|
r = self.client.post("/accounts/login/", form)
|
||||||
assert r.status_code == 302
|
self.assertRedirects(r, "/accounts/login_link_sent/")
|
||||||
|
|
||||||
# An user should have been created
|
self.profile.refresh_from_db()
|
||||||
self.assertEqual(User.objects.count(), 1)
|
self.assertIn("login", self.profile.token)
|
||||||
|
|
||||||
# And email sent
|
def test_it_handles_password(self):
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||||
subject = "Log in to %s" % settings.SITE_NAME
|
|
||||||
self.assertEqual(mail.outbox[0].subject, subject)
|
r = self.client.post("/accounts/login/", form)
|
||||||
|
self.assertRedirects(r, self.checks_url)
|
||||||
|
|
||||||
|
@override_settings(SECRET_KEY="test-secret")
|
||||||
|
def test_it_rate_limits_password_attempts(self):
|
||||||
|
# "d60d..." is sha1("alice@example.orgtest-secret")
|
||||||
|
obj = TokenBucket(value="pw-d60db3b2343e713a4de3e92d4eb417e4f05f06ab")
|
||||||
|
obj.tokens = 0
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||||
|
|
||||||
|
r = self.client.post("/accounts/login/", form)
|
||||||
|
self.assertContains(r, "Too many attempts")
|
||||||
|
|
||||||
|
def test_it_handles_password_login_with_redirect(self):
|
||||||
|
check = Check.objects.create(project=self.project)
|
||||||
|
|
||||||
|
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||||
|
|
||||||
|
samples = [self.channels_url, "/checks/%s/details/" % check.code]
|
||||||
|
|
||||||
|
for s in samples:
|
||||||
|
r = self.client.post("/accounts/login/?next=%s" % s, form)
|
||||||
|
self.assertRedirects(r, s)
|
||||||
|
|
||||||
|
def test_it_handles_bad_next_parameter(self):
|
||||||
|
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||||
|
|
||||||
|
samples = [
|
||||||
|
"/evil/",
|
||||||
|
f"https://example.org/projects/{self.project.code}/checks/",
|
||||||
|
]
|
||||||
|
|
||||||
|
for sample in samples:
|
||||||
|
r = self.client.post("/accounts/login/?next=" + sample, form)
|
||||||
|
self.assertRedirects(r, self.checks_url)
|
||||||
|
|
||||||
|
def test_it_handles_wrong_password(self):
|
||||||
|
form = {
|
||||||
|
"action": "login",
|
||||||
|
"email": "alice@example.org",
|
||||||
|
"password": "wrong password",
|
||||||
|
}
|
||||||
|
|
||||||
|
r = self.client.post("/accounts/login/", form)
|
||||||
|
self.assertContains(r, "Incorrect email or password")
|
||||||
|
|
||||||
@override_settings(REGISTRATION_OPEN=False)
|
@override_settings(REGISTRATION_OPEN=False)
|
||||||
def test_it_obeys_registration_open(self):
|
def test_it_obeys_registration_open(self):
|
||||||
form = {"email": "dan@example.org"}
|
r = self.client.get("/accounts/login/")
|
||||||
|
self.assertNotContains(r, "Create Your Account")
|
||||||
|
|
||||||
|
def test_it_redirects_to_webauthn_form(self):
|
||||||
|
Credential.objects.create(user=self.alice, name="Alices Key")
|
||||||
|
|
||||||
|
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||||
r = self.client.post("/accounts/login/", form)
|
r = self.client.post("/accounts/login/", form)
|
||||||
assert r.status_code == 200
|
self.assertRedirects(
|
||||||
self.assertContains(r, "Incorrect email")
|
r, "/accounts/login/two_factor/", fetch_redirect_response=False
|
||||||
|
)
|
||||||
|
|
||||||
def test_it_ignores_ces(self):
|
# It should not log the user in yet
|
||||||
alice = User(username="alice", email="alice@example.org")
|
self.assertNotIn("_auth_user_id", self.client.session)
|
||||||
alice.save()
|
|
||||||
|
|
||||||
form = {"email": "ALICE@EXAMPLE.ORG"}
|
# Instead, it should set 2fa_user_id in the session
|
||||||
|
user_id, email, valid_until = self.client.session["2fa_user"]
|
||||||
|
self.assertEqual(user_id, self.alice.id)
|
||||||
|
|
||||||
|
def test_it_redirects_to_totp_form(self):
|
||||||
|
self.profile.totp = "0" * 32
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||||
r = self.client.post("/accounts/login/", form)
|
r = self.client.post("/accounts/login/", form)
|
||||||
assert r.status_code == 302
|
self.assertRedirects(
|
||||||
|
r, "/accounts/login/two_factor/totp/", fetch_redirect_response=False
|
||||||
|
)
|
||||||
|
|
||||||
# There should be exactly one user:
|
# It should not log the user in yet
|
||||||
self.assertEqual(User.objects.count(), 1)
|
self.assertNotIn("_auth_user_id", self.client.session)
|
||||||
|
|
||||||
profile = Profile.objects.for_user(alice)
|
# Instead, it should set 2fa_user_id in the session
|
||||||
self.assertIn("login", profile.token)
|
user_id, email, valid_until = self.client.session["2fa_user"]
|
||||||
|
self.assertEqual(user_id, self.alice.id)
|
||||||
|
97
hc/accounts/tests/test_login_totp.py
Normal file
97
hc/accounts/tests/test_login_totp.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import time
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from hc.api.models import TokenBucket
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class LoginTotpTestCase(BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
# This is the user we're trying to authenticate
|
||||||
|
session = self.client.session
|
||||||
|
session["2fa_user"] = [self.alice.id, self.alice.email, (time.time()) + 300]
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
self.profile.totp = "0" * 32
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
self.url = "/accounts/login/two_factor/totp/"
|
||||||
|
self.checks_url = f"/projects/{self.project.code}/checks/"
|
||||||
|
|
||||||
|
def test_it_shows_form(self):
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "Please enter the six-digit code")
|
||||||
|
|
||||||
|
def test_it_requires_unauthenticated_user(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
def test_it_requires_totp_secret(self):
|
||||||
|
self.profile.totp = None
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
def test_it_rejects_changed_email(self):
|
||||||
|
session = self.client.session
|
||||||
|
session["2fa_user"] = [self.alice.id, "eve@example.org", int(time.time())]
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
def test_it_rejects_old_timestamp(self):
|
||||||
|
session = self.client.session
|
||||||
|
session["2fa_user"] = [self.alice.id, self.alice.email, int(time.time()) - 310]
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertRedirects(r, "/accounts/login/")
|
||||||
|
|
||||||
|
@patch("hc.accounts.views.pyotp.totp.TOTP")
|
||||||
|
def test_it_logs_in(self, mock_TOTP):
|
||||||
|
mock_TOTP.return_value.verify.return_value = True
|
||||||
|
|
||||||
|
r = self.client.post(self.url, {"code": "000000"})
|
||||||
|
self.assertRedirects(r, self.checks_url)
|
||||||
|
|
||||||
|
self.assertNotIn("2fa_user_id", self.client.session)
|
||||||
|
|
||||||
|
@patch("hc.accounts.views.pyotp.totp.TOTP")
|
||||||
|
def test_it_redirects_after_login(self, mock_TOTP):
|
||||||
|
mock_TOTP.return_value.verify.return_value = True
|
||||||
|
|
||||||
|
url = self.url + "?next=" + self.channels_url
|
||||||
|
r = self.client.post(url, {"code": "000000"})
|
||||||
|
self.assertRedirects(r, self.channels_url)
|
||||||
|
|
||||||
|
@patch("hc.accounts.views.pyotp.totp.TOTP")
|
||||||
|
def test_it_handles_authentication_failure(self, mock_TOTP):
|
||||||
|
mock_TOTP.return_value.verify.return_value = False
|
||||||
|
|
||||||
|
r = self.client.post(self.url, {"code": "000000"})
|
||||||
|
self.assertContains(r, "The code you entered was incorrect.")
|
||||||
|
|
||||||
|
def test_it_uses_rate_limiting(self):
|
||||||
|
obj = TokenBucket(value=f"totp-{self.alice.id}")
|
||||||
|
obj.tokens = 0
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
r = self.client.post(self.url, {"code": "000000"})
|
||||||
|
self.assertContains(r, "Too Many Requests")
|
||||||
|
|
||||||
|
@patch("hc.accounts.views.pyotp.totp.TOTP")
|
||||||
|
def test_it_rejects_used_code(self, mock_TOTP):
|
||||||
|
mock_TOTP.return_value.verify.return_value = True
|
||||||
|
|
||||||
|
obj = TokenBucket(value=f"totpc-{self.alice.id}-000000")
|
||||||
|
obj.tokens = 0
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
r = self.client.post(self.url, {"code": "000000"})
|
||||||
|
self.assertContains(r, "Too Many Requests")
|
149
hc/accounts/tests/test_login_webauthn.py
Normal file
149
hc/accounts/tests/test_login_webauthn.py
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import time
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(RP_ID="testserver")
|
||||||
|
class LoginWebAuthnTestCase(BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
# This is the user we're trying to authenticate
|
||||||
|
session = self.client.session
|
||||||
|
session["2fa_user"] = [self.alice.id, self.alice.email, (time.time()) + 300]
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
self.url = "/accounts/login/two_factor/"
|
||||||
|
self.checks_url = f"/projects/{self.project.code}/checks/"
|
||||||
|
|
||||||
|
def test_it_shows_form(self):
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "Waiting for security key")
|
||||||
|
self.assertNotContains(r, "Use authenticator app")
|
||||||
|
|
||||||
|
# It should put a "state" key in the session:
|
||||||
|
self.assertIn("state", self.client.session)
|
||||||
|
|
||||||
|
def test_it_shows_totp_option(self):
|
||||||
|
self.profile.totp = "0" * 32
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "Use authenticator app")
|
||||||
|
|
||||||
|
def test_it_preserves_next_parameter_in_totp_url(self):
|
||||||
|
self.profile.totp = "0" * 32
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
url = self.url + "?next=" + self.channels_url
|
||||||
|
r = self.client.get(url)
|
||||||
|
self.assertContains(r, "/login/two_factor/totp/?next=" + self.channels_url)
|
||||||
|
|
||||||
|
def test_it_requires_unauthenticated_user(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
def test_it_rejects_changed_email(self):
|
||||||
|
session = self.client.session
|
||||||
|
session["2fa_user"] = [self.alice.id, "eve@example.org", int(time.time())]
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
def test_it_rejects_old_timestamp(self):
|
||||||
|
session = self.client.session
|
||||||
|
session["2fa_user"] = [self.alice.id, self.alice.email, int(time.time()) - 310]
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertRedirects(r, "/accounts/login/")
|
||||||
|
|
||||||
|
@override_settings(RP_ID=None)
|
||||||
|
def test_it_requires_rp_id(self):
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertEqual(r.status_code, 500)
|
||||||
|
|
||||||
|
@patch("hc.accounts.views._check_credential")
|
||||||
|
def test_it_logs_in(self, mock_check_credential):
|
||||||
|
mock_check_credential.return_value = True
|
||||||
|
|
||||||
|
session = self.client.session
|
||||||
|
session["state"] = "dummy-state"
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": "My New Key",
|
||||||
|
"credential_id": "e30=",
|
||||||
|
"client_data_json": "e30=",
|
||||||
|
"authenticator_data": "e30=",
|
||||||
|
"signature": "e30=",
|
||||||
|
}
|
||||||
|
|
||||||
|
r = self.client.post(self.url, payload)
|
||||||
|
self.assertRedirects(r, self.checks_url)
|
||||||
|
|
||||||
|
self.assertNotIn("state", self.client.session)
|
||||||
|
self.assertNotIn("2fa_user_id", self.client.session)
|
||||||
|
|
||||||
|
@patch("hc.accounts.views._check_credential")
|
||||||
|
def test_it_redirects_after_login(self, mock_check_credential):
|
||||||
|
mock_check_credential.return_value = True
|
||||||
|
|
||||||
|
session = self.client.session
|
||||||
|
session["state"] = "dummy-state"
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": "My New Key",
|
||||||
|
"credential_id": "e30=",
|
||||||
|
"client_data_json": "e30=",
|
||||||
|
"authenticator_data": "e30=",
|
||||||
|
"signature": "e30=",
|
||||||
|
}
|
||||||
|
|
||||||
|
url = self.url + "?next=" + self.channels_url
|
||||||
|
r = self.client.post(url, payload)
|
||||||
|
self.assertRedirects(r, self.channels_url)
|
||||||
|
|
||||||
|
@patch("hc.accounts.views._check_credential")
|
||||||
|
def test_it_handles_bad_base64(self, mock_check_credential):
|
||||||
|
mock_check_credential.return_value = None
|
||||||
|
|
||||||
|
session = self.client.session
|
||||||
|
session["state"] = "dummy-state"
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": "My New Key",
|
||||||
|
"credential_id": "this is not base64 data",
|
||||||
|
"client_data_json": "e30=",
|
||||||
|
"authenticator_data": "e30=",
|
||||||
|
"signature": "e30=",
|
||||||
|
}
|
||||||
|
|
||||||
|
r = self.client.post(self.url, payload)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
@patch("hc.accounts.views._check_credential")
|
||||||
|
def test_it_handles_authentication_failure(self, mock_check_credential):
|
||||||
|
mock_check_credential.return_value = None
|
||||||
|
|
||||||
|
session = self.client.session
|
||||||
|
session["state"] = "dummy-state"
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": "My New Key",
|
||||||
|
"credential_id": "e30=",
|
||||||
|
"client_data_json": "e30=",
|
||||||
|
"authenticator_data": "e30=",
|
||||||
|
"signature": "e30=",
|
||||||
|
}
|
||||||
|
|
||||||
|
r = self.client.post(self.url, payload)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
@ -1,60 +1,113 @@
|
|||||||
from datetime import timedelta as td
|
from datetime import timedelta as td
|
||||||
|
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
from hc.api.models import Check
|
||||||
from hc.test import BaseTestCase
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
class NotificationsTestCase(BaseTestCase):
|
class NotificationsTestCase(BaseTestCase):
|
||||||
|
url = "/accounts/profile/notifications/"
|
||||||
|
|
||||||
def test_it_saves_reports_allowed_true(self):
|
def _payload(self, **kwargs):
|
||||||
self.profile.reports_allowed = False
|
result = {"reports": "monthly", "nag_period": "0", "tz": "Europe/Riga"}
|
||||||
|
result.update(kwargs)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def test_it_saves_reports_monthly(self):
|
||||||
|
self.profile.reports = "off"
|
||||||
self.profile.save()
|
self.profile.save()
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
form = {"reports_allowed": "on", "nag_period": "0"}
|
r = self.client.post(self.url, self._payload())
|
||||||
r = self.client.post("/accounts/profile/notifications/", form)
|
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
self.assertTrue(self.profile.reports_allowed)
|
self.assertEqual(self.profile.reports, "monthly")
|
||||||
self.assertIsNotNone(self.profile.next_report_date)
|
self.assertEqual(self.profile.next_report_date.day, 1)
|
||||||
|
|
||||||
def test_it_saves_reports_allowed_false(self):
|
def test_it_saves_reports_weekly(self):
|
||||||
self.profile.reports_allowed = True
|
self.profile.reports = "off"
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.post(self.url, self._payload(reports="weekly"))
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
self.assertEqual(self.profile.reports, "weekly")
|
||||||
|
self.assertEqual(self.profile.next_report_date.weekday(), 0)
|
||||||
|
|
||||||
|
def test_it_saves_reports_off(self):
|
||||||
|
self.profile.reports = "monthly"
|
||||||
self.profile.next_report_date = now()
|
self.profile.next_report_date = now()
|
||||||
self.profile.save()
|
self.profile.save()
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
form = {"nag_period": "0"}
|
r = self.client.post(self.url, self._payload(reports="off"))
|
||||||
r = self.client.post("/accounts/profile/notifications/", form)
|
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
self.assertFalse(self.profile.reports_allowed)
|
self.assertEqual(self.profile.reports, "off")
|
||||||
self.assertIsNone(self.profile.next_report_date)
|
self.assertIsNone(self.profile.next_report_date)
|
||||||
|
|
||||||
def test_it_saves_hourly_nag_period(self):
|
def test_it_sets_next_nag_date_when_setting_hourly_nag_period(self):
|
||||||
|
Check.objects.create(project=self.project, status="down")
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
form = {"nag_period": "3600"}
|
r = self.client.post(self.url, self._payload(nag_period="3600"))
|
||||||
r = self.client.post("/accounts/profile/notifications/", form)
|
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
self.assertEqual(self.profile.nag_period.total_seconds(), 3600)
|
self.assertEqual(self.profile.nag_period.total_seconds(), 3600)
|
||||||
self.assertIsNotNone(self.profile.next_nag_date)
|
self.assertIsNotNone(self.profile.next_nag_date)
|
||||||
|
|
||||||
|
def test_it_clears_next_nag_date_when_setting_hourly_nag_period(self):
|
||||||
|
self.profile.next_nag_date = now() + td(minutes=30)
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.post(self.url, self._payload(nag_period="3600"))
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
self.assertEqual(self.profile.nag_period.total_seconds(), 3600)
|
||||||
|
self.assertIsNone(self.profile.next_nag_date)
|
||||||
|
|
||||||
def test_it_does_not_save_nonstandard_nag_period(self):
|
def test_it_does_not_save_nonstandard_nag_period(self):
|
||||||
self.profile.nag_period = td(seconds=3600)
|
self.profile.nag_period = td(seconds=3600)
|
||||||
self.profile.save()
|
self.profile.save()
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
form = {"nag_period": "1234"}
|
r = self.client.post(self.url, self._payload(nag_period="1234"))
|
||||||
r = self.client.post("/accounts/profile/notifications/", form)
|
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
self.assertEqual(self.profile.nag_period.total_seconds(), 3600)
|
self.assertEqual(self.profile.nag_period.total_seconds(), 3600)
|
||||||
|
|
||||||
|
def test_it_saves_tz(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.post(self.url, self._payload())
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
self.assertEqual(self.profile.tz, "Europe/Riga")
|
||||||
|
|
||||||
|
def test_it_ignores_bad_tz(self):
|
||||||
|
self.profile.tz = "Europe/Riga"
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.post(self.url, self._payload(reports="weekly", tz="Foo/Bar"))
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
self.assertEqual(self.profile.reports, "weekly")
|
||||||
|
self.assertEqual(self.profile.tz, "Europe/Riga")
|
||||||
|
@ -1,174 +1,105 @@
|
|||||||
from datetime import timedelta as td
|
from django.test.utils import override_settings
|
||||||
from django.core import mail
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.utils.timezone import now
|
|
||||||
from hc.test import BaseTestCase
|
from hc.test import BaseTestCase
|
||||||
from hc.accounts.models import Member
|
from hc.accounts.models import Credential
|
||||||
from hc.api.models import Check
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileTestCase(BaseTestCase):
|
class ProfileTestCase(BaseTestCase):
|
||||||
|
def test_it_shows_profile_page(self):
|
||||||
def test_it_sends_set_password_link(self):
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
form = {"set_password": "1"}
|
r = self.client.get("/accounts/profile/")
|
||||||
r = self.client.post("/accounts/profile/", form)
|
self.assertContains(r, "Email and Password")
|
||||||
assert r.status_code == 302
|
self.assertContains(r, "Change Password")
|
||||||
|
self.assertContains(r, "Set Up Authenticator App")
|
||||||
|
|
||||||
# profile.token should be set now
|
def test_leaving_works(self):
|
||||||
self.profile.refresh_from_db()
|
|
||||||
token = self.profile.token
|
|
||||||
self.assertTrue(len(token) > 10)
|
|
||||||
|
|
||||||
# And an email should have been sent
|
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
|
||||||
expected_subject = "Set password on %s" % settings.SITE_NAME
|
|
||||||
self.assertEqual(mail.outbox[0].subject, expected_subject)
|
|
||||||
|
|
||||||
def test_it_creates_api_key(self):
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
|
||||||
|
|
||||||
form = {"create_api_key": "1"}
|
|
||||||
r = self.client.post("/accounts/profile/", form)
|
|
||||||
self.assertEqual(r.status_code, 200)
|
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
|
||||||
api_key = self.profile.api_key
|
|
||||||
self.assertTrue(len(api_key) > 10)
|
|
||||||
|
|
||||||
def test_it_revokes_api_key(self):
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
|
||||||
|
|
||||||
form = {"revoke_api_key": "1"}
|
|
||||||
r = self.client.post("/accounts/profile/", form)
|
|
||||||
assert r.status_code == 200
|
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
|
||||||
self.assertEqual(self.profile.api_key, "")
|
|
||||||
|
|
||||||
def test_it_sends_report(self):
|
|
||||||
check = Check(name="Test Check", user=self.alice)
|
|
||||||
check.last_ping = now()
|
|
||||||
check.save()
|
|
||||||
|
|
||||||
sent = self.profile.send_report()
|
|
||||||
self.assertTrue(sent)
|
|
||||||
|
|
||||||
# And an email should have been sent
|
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
|
||||||
message = mail.outbox[0]
|
|
||||||
|
|
||||||
self.assertEqual(message.subject, 'Monthly Report')
|
|
||||||
self.assertIn("Test Check", message.body)
|
|
||||||
|
|
||||||
def test_it_sends_nag(self):
|
|
||||||
check = Check(name="Test Check", user=self.alice)
|
|
||||||
check.status = "down"
|
|
||||||
check.last_ping = now()
|
|
||||||
check.save()
|
|
||||||
|
|
||||||
self.profile.nag_period = td(hours=1)
|
|
||||||
self.profile.save()
|
|
||||||
|
|
||||||
sent = self.profile.send_report(nag=True)
|
|
||||||
self.assertTrue(sent)
|
|
||||||
|
|
||||||
# And an email should have been sent
|
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
|
||||||
message = mail.outbox[0]
|
|
||||||
|
|
||||||
self.assertEqual(message.subject, 'Reminder: 1 check still down')
|
|
||||||
self.assertIn("Test Check", message.body)
|
|
||||||
|
|
||||||
def test_it_skips_nag_if_none_down(self):
|
|
||||||
check = Check(name="Test Check", user=self.alice)
|
|
||||||
check.last_ping = now()
|
|
||||||
check.save()
|
|
||||||
|
|
||||||
self.profile.nag_period = td(hours=1)
|
|
||||||
self.profile.save()
|
|
||||||
|
|
||||||
sent = self.profile.send_report(nag=True)
|
|
||||||
self.assertFalse(sent)
|
|
||||||
|
|
||||||
self.assertEqual(len(mail.outbox), 0)
|
|
||||||
|
|
||||||
def test_it_adds_team_member(self):
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
|
||||||
|
|
||||||
form = {"invite_team_member": "1", "email": "frank@example.org"}
|
|
||||||
r = self.client.post("/accounts/profile/", form)
|
|
||||||
self.assertEqual(r.status_code, 200)
|
|
||||||
|
|
||||||
member_emails = set()
|
|
||||||
for member in self.profile.member_set.all():
|
|
||||||
member_emails.add(member.user.email)
|
|
||||||
|
|
||||||
self.assertEqual(len(member_emails), 2)
|
|
||||||
self.assertTrue("frank@example.org" in member_emails)
|
|
||||||
|
|
||||||
# And an email should have been sent
|
|
||||||
subj = ('You have been invited to join'
|
|
||||||
' alice@example.org on %s' % settings.SITE_NAME)
|
|
||||||
self.assertEqual(mail.outbox[0].subject, subj)
|
|
||||||
|
|
||||||
def test_it_checks_team_size(self):
|
|
||||||
self.profile.team_limit = 0
|
|
||||||
self.profile.save()
|
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
|
||||||
|
|
||||||
form = {"invite_team_member": "1", "email": "frank@example.org"}
|
|
||||||
r = self.client.post("/accounts/profile/", form)
|
|
||||||
self.assertEqual(r.status_code, 403)
|
|
||||||
|
|
||||||
def test_it_removes_team_member(self):
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
|
||||||
|
|
||||||
form = {"remove_team_member": "1", "email": "bob@example.org"}
|
|
||||||
r = self.client.post("/accounts/profile/", form)
|
|
||||||
self.assertEqual(r.status_code, 200)
|
|
||||||
|
|
||||||
self.assertEqual(Member.objects.count(), 0)
|
|
||||||
|
|
||||||
self.bobs_profile.refresh_from_db()
|
|
||||||
self.assertEqual(self.bobs_profile.current_team, None)
|
|
||||||
|
|
||||||
def test_it_sets_team_name(self):
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
|
||||||
|
|
||||||
form = {"set_team_name": "1", "team_name": "Alpha Team"}
|
|
||||||
r = self.client.post("/accounts/profile/", form)
|
|
||||||
self.assertEqual(r.status_code, 200)
|
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
|
||||||
self.assertEqual(self.profile.team_name, "Alpha Team")
|
|
||||||
|
|
||||||
def test_it_switches_to_own_team(self):
|
|
||||||
self.client.login(username="bob@example.org", password="password")
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
|
||||||
self.client.get("/accounts/profile/")
|
form = {"code": str(self.project.code), "leave_project": "1"}
|
||||||
|
r = self.client.post("/accounts/profile/", form)
|
||||||
|
self.assertContains(r, "Left project <strong>Alices Project</strong>")
|
||||||
|
self.assertNotContains(r, "Member")
|
||||||
|
|
||||||
# After visiting the profile page, team should be switched back
|
|
||||||
# to user's default team.
|
|
||||||
self.bobs_profile.refresh_from_db()
|
self.bobs_profile.refresh_from_db()
|
||||||
self.assertEqual(self.bobs_profile.current_team, self.bobs_profile)
|
self.assertFalse(self.bob.memberships.exists())
|
||||||
|
|
||||||
|
def test_leaving_checks_membership(self):
|
||||||
|
self.client.login(username="charlie@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"code": str(self.project.code), "leave_project": "1"}
|
||||||
|
r = self.client.post("/accounts/profile/", form)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
def test_it_shows_project_membership(self):
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get("/accounts/profile/")
|
||||||
|
self.assertContains(r, "Alices Project")
|
||||||
|
self.assertContains(r, "Member")
|
||||||
|
|
||||||
|
def test_it_shows_readonly_project_membership(self):
|
||||||
|
self.bobs_membership.role = "r"
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get("/accounts/profile/")
|
||||||
|
self.assertContains(r, "Alices Project")
|
||||||
|
self.assertContains(r, "Read-only")
|
||||||
|
|
||||||
|
def test_it_handles_no_projects(self):
|
||||||
|
self.project.delete()
|
||||||
|
|
||||||
def test_it_sends_change_email_link(self):
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
form = {"change_email": "1"}
|
r = self.client.get("/accounts/profile/")
|
||||||
r = self.client.post("/accounts/profile/", form)
|
self.assertContains(r, "You do not have any projects. Create one!")
|
||||||
assert r.status_code == 302
|
|
||||||
|
|
||||||
# profile.token should be set now
|
@override_settings(RP_ID=None)
|
||||||
self.profile.refresh_from_db()
|
def test_it_hides_security_keys_bits_if_rp_id_not_set(self):
|
||||||
token = self.profile.token
|
self.client.login(username="alice@example.org", password="password")
|
||||||
self.assertTrue(len(token) > 10)
|
|
||||||
|
|
||||||
# And an email should have been sent
|
r = self.client.get("/accounts/profile/")
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertContains(r, "Two-factor Authentication")
|
||||||
expected_subject = "Change email address on %s" % settings.SITE_NAME
|
self.assertNotContains(r, "Security keys")
|
||||||
self.assertEqual(mail.outbox[0].subject, expected_subject)
|
self.assertNotContains(r, "Add Security Key")
|
||||||
|
|
||||||
|
@override_settings(RP_ID="testserver")
|
||||||
|
def test_it_handles_no_credentials(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get("/accounts/profile/")
|
||||||
|
self.assertContains(r, "Two-factor Authentication")
|
||||||
|
self.assertContains(r, "Your account does not have any configured two-factor")
|
||||||
|
|
||||||
|
@override_settings(RP_ID="testserver")
|
||||||
|
def test_it_shows_security_key(self):
|
||||||
|
Credential.objects.create(user=self.alice, name="Alices Key")
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.get("/accounts/profile/")
|
||||||
|
self.assertContains(r, "Alices Key")
|
||||||
|
|
||||||
|
def test_it_handles_unusable_password(self):
|
||||||
|
self.alice.set_unusable_password()
|
||||||
|
self.alice.save()
|
||||||
|
|
||||||
|
# Authenticate using the ProfileBackend and a token:
|
||||||
|
token = self.profile.prepare_token("login")
|
||||||
|
self.client.login(username="alice", token=token)
|
||||||
|
|
||||||
|
r = self.client.get("/accounts/profile/")
|
||||||
|
self.assertContains(r, "Set Password")
|
||||||
|
self.assertNotContains(r, "Change Password")
|
||||||
|
|
||||||
|
def test_it_shows_totp(self):
|
||||||
|
self.profile.totp = "0" * 32
|
||||||
|
self.profile.totp_created = "2020-01-01T00:00:00+00:00"
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get("/accounts/profile/")
|
||||||
|
self.assertContains(r, "Enabled")
|
||||||
|
self.assertContains(r, "configured on Jan 1, 2020")
|
||||||
|
self.assertNotContains(r, "Set Up Authenticator App")
|
||||||
|
114
hc/accounts/tests/test_profile_model.py
Normal file
114
hc/accounts/tests/test_profile_model.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
from datetime import datetime, timedelta as td
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from django.core import mail
|
||||||
|
from django.utils.timezone import now, utc
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
from hc.api.models import Check
|
||||||
|
|
||||||
|
CURRENT_TIME = datetime(2020, 1, 15, tzinfo=utc)
|
||||||
|
MOCK_NOW = Mock(return_value=CURRENT_TIME)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileModelTestCase(BaseTestCase):
|
||||||
|
@patch("hc.lib.date.timezone.now", MOCK_NOW)
|
||||||
|
def test_it_sends_report(self):
|
||||||
|
check = Check(project=self.project, name="Test Check")
|
||||||
|
check.last_ping = now()
|
||||||
|
check.save()
|
||||||
|
|
||||||
|
sent = self.profile.send_report()
|
||||||
|
self.assertTrue(sent)
|
||||||
|
|
||||||
|
# And an email should have been sent
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
message = mail.outbox[0]
|
||||||
|
|
||||||
|
self.assertEqual(message.subject, "Monthly Report")
|
||||||
|
self.assertIn("Test Check", message.body)
|
||||||
|
|
||||||
|
html, _ = message.alternatives[0]
|
||||||
|
self.assertNotIn("Jan. 2020", html)
|
||||||
|
self.assertIn("Dec. 2019", html)
|
||||||
|
self.assertIn("Nov. 2019", html)
|
||||||
|
self.assertNotIn("Oct. 2019", html)
|
||||||
|
|
||||||
|
def test_it_skips_report_if_no_pings(self):
|
||||||
|
check = Check(project=self.project, name="Test Check")
|
||||||
|
check.save()
|
||||||
|
|
||||||
|
sent = self.profile.send_report()
|
||||||
|
self.assertFalse(sent)
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
|
def test_it_skips_report_if_no_recent_pings(self):
|
||||||
|
check = Check(project=self.project, name="Test Check")
|
||||||
|
check.last_ping = now() - td(days=365)
|
||||||
|
check.save()
|
||||||
|
|
||||||
|
sent = self.profile.send_report()
|
||||||
|
self.assertFalse(sent)
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
|
def test_it_sends_nag(self):
|
||||||
|
check = Check(project=self.project, name="Test Check")
|
||||||
|
check.status = "down"
|
||||||
|
check.last_ping = now()
|
||||||
|
check.save()
|
||||||
|
|
||||||
|
self.profile.nag_period = td(hours=1)
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
sent = self.profile.send_report(nag=True)
|
||||||
|
self.assertTrue(sent)
|
||||||
|
|
||||||
|
# And an email should have been sent
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
message = mail.outbox[0]
|
||||||
|
|
||||||
|
self.assertEqual(message.subject, "Reminder: 1 check still down")
|
||||||
|
self.assertIn("Test Check", message.body)
|
||||||
|
|
||||||
|
def test_it_skips_nag_if_none_down(self):
|
||||||
|
check = Check(project=self.project, name="Test Check")
|
||||||
|
check.last_ping = now()
|
||||||
|
check.save()
|
||||||
|
|
||||||
|
self.profile.nag_period = td(hours=1)
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
sent = self.profile.send_report(nag=True)
|
||||||
|
self.assertFalse(sent)
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
|
def test_it_sets_next_nag_date(self):
|
||||||
|
Check.objects.create(project=self.project, status="down")
|
||||||
|
|
||||||
|
self.profile.nag_period = td(hours=1)
|
||||||
|
self.profile.update_next_nag_date()
|
||||||
|
|
||||||
|
self.assertTrue(self.profile.next_nag_date)
|
||||||
|
|
||||||
|
def test_it_does_not_set_next_nag_date_if_no_nag_period(self):
|
||||||
|
Check.objects.create(project=self.project, status="down")
|
||||||
|
self.profile.update_next_nag_date()
|
||||||
|
self.assertIsNone(self.profile.next_nag_date)
|
||||||
|
|
||||||
|
def test_it_does_not_update_existing_next_nag_date(self):
|
||||||
|
Check.objects.create(project=self.project, status="down")
|
||||||
|
|
||||||
|
original_nag_date = now() - td(minutes=30)
|
||||||
|
|
||||||
|
self.profile.next_nag_date = original_nag_date
|
||||||
|
self.profile.nag_period = td(hours=1)
|
||||||
|
self.profile.update_next_nag_date()
|
||||||
|
|
||||||
|
self.assertEqual(self.profile.next_nag_date, original_nag_date)
|
||||||
|
|
||||||
|
def test_it_clears_next_nag_date(self):
|
||||||
|
self.profile.next_nag_date = now()
|
||||||
|
self.profile.update_next_nag_date()
|
||||||
|
self.assertIsNone(self.profile.next_nag_date)
|
352
hc/accounts/tests/test_project.py
Normal file
352
hc/accounts/tests/test_project.py
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
from django.core import mail
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
from hc.accounts.models import Member, Project
|
||||||
|
from hc.api.models import TokenBucket
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTestCase(BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.url = "/projects/%s/settings/" % self.project.code
|
||||||
|
|
||||||
|
def test_it_checks_access(self):
|
||||||
|
self.client.login(username="charlie@example.org", password="password")
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
def test_it_allows_team_access(self):
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "Change Project Name")
|
||||||
|
|
||||||
|
def test_it_shows_api_keys(self):
|
||||||
|
self.project.api_key_readonly = "R" * 32
|
||||||
|
self.project.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"show_api_keys": "1"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
self.assertContains(r, "X" * 32)
|
||||||
|
self.assertContains(r, "R" * 32)
|
||||||
|
self.assertContains(r, "Prometheus metrics endpoint")
|
||||||
|
|
||||||
|
def test_it_creates_api_key(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"create_api_keys": "1"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
self.project.refresh_from_db()
|
||||||
|
api_key = self.project.api_key
|
||||||
|
self.assertTrue(len(api_key) > 10)
|
||||||
|
self.assertFalse("b'" in api_key)
|
||||||
|
|
||||||
|
def test_it_requires_rw_access_to_create_api_key(self):
|
||||||
|
self.bobs_membership.role = "r"
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"create_api_keys": "1"})
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
|
||||||
|
def test_it_revokes_api_key(self):
|
||||||
|
self.project.api_key_readonly = "R" * 32
|
||||||
|
self.project.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"revoke_api_keys": "1"})
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
self.project.refresh_from_db()
|
||||||
|
self.assertEqual(self.project.api_key, "")
|
||||||
|
self.assertEqual(self.project.api_key_readonly, "")
|
||||||
|
|
||||||
|
def test_it_requires_rw_access_to_revoke_api_key(self):
|
||||||
|
self.bobs_membership.role = "r"
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"revoke_api_keys": "1"})
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
|
||||||
|
def test_it_adds_team_member(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "w"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
members = self.project.member_set.all()
|
||||||
|
self.assertEqual(members.count(), 2)
|
||||||
|
|
||||||
|
member = Member.objects.get(
|
||||||
|
project=self.project, user__email="frank@example.org"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The read-write flag should be set
|
||||||
|
self.assertEqual(member.role, member.Role.REGULAR)
|
||||||
|
|
||||||
|
# The new user should not have their own project
|
||||||
|
self.assertFalse(member.user.project_set.exists())
|
||||||
|
|
||||||
|
# And an email should have been sent
|
||||||
|
subj = f"You have been invited to join Alices Project on {settings.SITE_NAME}"
|
||||||
|
self.assertHTMLEqual(mail.outbox[0].subject, subj)
|
||||||
|
|
||||||
|
def test_it_adds_readonly_team_member(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "r"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
member = Member.objects.get(
|
||||||
|
project=self.project, user__email="frank@example.org"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(member.role, member.Role.READONLY)
|
||||||
|
|
||||||
|
def test_it_adds_manager_team_member(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "m"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
member = Member.objects.get(
|
||||||
|
project=self.project, user__email="frank@example.org"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The new user should have role manager
|
||||||
|
self.assertEqual(member.role, member.Role.MANAGER)
|
||||||
|
|
||||||
|
def test_it_adds_member_from_another_team(self):
|
||||||
|
# With team limit at zero, we should not be able to invite any new users
|
||||||
|
self.profile.team_limit = 0
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
# But Charlie will have an existing membership in another Alice's project
|
||||||
|
# so Alice *should* be able to invite Charlie:
|
||||||
|
p2 = Project.objects.create(owner=self.alice)
|
||||||
|
Member.objects.create(user=self.charlie, project=p2)
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
form = {"invite_team_member": "1", "email": "charlie@example.org", "role": "r"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
q = Member.objects.filter(project=self.project, user=self.charlie)
|
||||||
|
self.assertEqual(q.count(), 1)
|
||||||
|
|
||||||
|
# And this should not have affected the rate limit:
|
||||||
|
q = TokenBucket.objects.filter(value="invite-%d" % self.alice.id)
|
||||||
|
self.assertFalse(q.exists())
|
||||||
|
|
||||||
|
def test_it_rejects_duplicate_membership(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"invite_team_member": "1", "email": "bob@example.org", "role": "r"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertContains(r, "bob@example.org is already a member")
|
||||||
|
|
||||||
|
# The number of memberships should have not increased
|
||||||
|
self.assertEqual(self.project.member_set.count(), 1)
|
||||||
|
|
||||||
|
def test_it_rejects_owner_as_a_member(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"invite_team_member": "1", "email": "alice@example.org", "role": "r"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertContains(r, "alice@example.org is already a member")
|
||||||
|
|
||||||
|
# The number of memberships should have not increased
|
||||||
|
self.assertEqual(self.project.member_set.count(), 1)
|
||||||
|
|
||||||
|
def test_it_rejects_too_long_email_addresses(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
aaa = "a" * 300
|
||||||
|
form = {
|
||||||
|
"invite_team_member": "1",
|
||||||
|
"email": f"frank+{aaa}@example.org",
|
||||||
|
"role": "r",
|
||||||
|
}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
# No email should have been sent
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
|
@override_settings(SECRET_KEY="test-secret")
|
||||||
|
def test_it_rate_limits_invites(self):
|
||||||
|
obj = TokenBucket(value="invite-%d" % self.alice.id)
|
||||||
|
obj.tokens = 0
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "r"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertContains(r, "Too Many Requests")
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
|
def test_it_lets_manager_add_team_member(self):
|
||||||
|
# Bob is a manager:
|
||||||
|
self.bobs_membership.role = "m"
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "w"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
Member.objects.get(project=self.project, user__email="frank@example.org")
|
||||||
|
|
||||||
|
def test_it_does_not_allow_regular_member_invite_team_members(self):
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "w"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
|
||||||
|
def test_it_checks_team_size(self):
|
||||||
|
self.profile.team_limit = 0
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "r"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
|
||||||
|
def test_it_lets_owner_remove_team_member(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"remove_team_member": "1", "email": "bob@example.org"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
self.assertFalse(Member.objects.exists())
|
||||||
|
|
||||||
|
def test_it_lets_manager_remove_team_member(self):
|
||||||
|
# Bob is a manager:
|
||||||
|
self.bobs_membership.role = "m"
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
# Bob will try to remove this membership:
|
||||||
|
Member.objects.create(user=self.charlie, project=self.project)
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
form = {"remove_team_member": "1", "email": "charlie@example.org"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
q = Member.objects.filter(user=self.charlie, project=self.project)
|
||||||
|
self.assertFalse(q.exists())
|
||||||
|
|
||||||
|
def test_it_does_not_allow_regular_member_remove_team_member(self):
|
||||||
|
# Bob will try to remove this membership:
|
||||||
|
Member.objects.create(user=self.charlie, project=self.project)
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
form = {"remove_team_member": "1", "email": "charlie@example.org"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
|
||||||
|
def test_it_rejects_manager_remove_self(self):
|
||||||
|
self.bobs_membership.role = "m"
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"remove_team_member": "1", "email": "bob@example.org"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
# The number of memberships should have not decreased
|
||||||
|
self.assertEqual(self.project.member_set.count(), 1)
|
||||||
|
|
||||||
|
def test_it_checks_membership_when_removing_team_member(self):
|
||||||
|
self.client.login(username="charlie@example.org", password="password")
|
||||||
|
|
||||||
|
url = "/projects/%s/settings/" % self.charlies_project.code
|
||||||
|
form = {"remove_team_member": "1", "email": "alice@example.org"}
|
||||||
|
r = self.client.post(url, form)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
def test_it_sets_project_name(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"set_project_name": "1", "name": "Alpha Team"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
self.project.refresh_from_db()
|
||||||
|
self.assertEqual(self.project.name, "Alpha Team")
|
||||||
|
|
||||||
|
def test_it_requires_rw_access_to_set_project_name(self):
|
||||||
|
self.bobs_membership.role = "r"
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
form = {"set_project_name": "1", "name": "Alpha Team"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
|
||||||
|
def test_it_shows_invite_suggestions(self):
|
||||||
|
p2 = Project.objects.create(owner=self.alice)
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get("/projects/%s/settings/" % p2.code)
|
||||||
|
self.assertContains(r, "Add Users from Other Teams")
|
||||||
|
self.assertContains(r, "bob@example.org")
|
||||||
|
|
||||||
|
def test_it_requires_rw_access_to_update_project_name(self):
|
||||||
|
self.bobs_membership.role = "r"
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"set_project_name": "1", "name": "Alpha Team"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
|
||||||
|
def test_it_hides_actions_for_readonly_users(self):
|
||||||
|
self.bobs_membership.role = "r"
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertNotContains(r, "#set-project-name-modal", status_code=200)
|
||||||
|
self.assertNotContains(r, "Show API Keys")
|
||||||
|
|
||||||
|
@override_settings(PROMETHEUS_ENABLED=False)
|
||||||
|
def test_it_hides_prometheus_link_if_prometheus_not_enabled(self):
|
||||||
|
self.project.api_key_readonly = "R" * 32
|
||||||
|
self.project.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"show_api_keys": "1"})
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
self.assertNotContains(r, "Prometheus metrics endpoint")
|
||||||
|
|
||||||
|
def test_it_requires_rw_access_to_show_api_key(self):
|
||||||
|
self.bobs_membership.role = "r"
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"show_api_keys": "1"})
|
||||||
|
self.assertEqual(r.status_code, 403)
|
49
hc/accounts/tests/test_project_model.py
Normal file
49
hc/accounts/tests/test_project_model.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
from hc.test import BaseTestCase
|
||||||
|
from hc.accounts.models import Member, Project
|
||||||
|
from hc.api.models import Check, Channel
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectModelTestCase(BaseTestCase):
|
||||||
|
def test_num_checks_available_handles_multiple_projects(self):
|
||||||
|
# One check in Alice's primary project:
|
||||||
|
Check.objects.create(project=self.project)
|
||||||
|
|
||||||
|
# One check in Alice's secondary project:
|
||||||
|
p2 = Project.objects.create(owner=self.alice)
|
||||||
|
Check.objects.create(project=p2)
|
||||||
|
|
||||||
|
self.assertEqual(self.project.num_checks_available(), 18)
|
||||||
|
|
||||||
|
def test_it_handles_zero_broken_channels(self):
|
||||||
|
Channel.objects.create(kind="webhook", last_error="", project=self.project)
|
||||||
|
|
||||||
|
self.assertFalse(self.project.have_channel_issues())
|
||||||
|
|
||||||
|
def test_it_handles_one_broken_channel(self):
|
||||||
|
Channel.objects.create(kind="webhook", last_error="x", project=self.project)
|
||||||
|
|
||||||
|
self.assertTrue(self.project.have_channel_issues())
|
||||||
|
|
||||||
|
def test_it_handles_no_channels(self):
|
||||||
|
# It's an issue if the project has no channels at all:
|
||||||
|
self.assertTrue(self.project.have_channel_issues())
|
||||||
|
|
||||||
|
def test_it_allows_third_user(self):
|
||||||
|
# Alice is the owner, and Bob is invited -- there is space for the third user:
|
||||||
|
self.assertTrue(self.project.can_invite_new_users())
|
||||||
|
|
||||||
|
def test_it_allows_same_user_in_multiple_projects(self):
|
||||||
|
p2 = Project.objects.create(owner=self.alice)
|
||||||
|
Member.objects.create(user=self.bob, project=p2)
|
||||||
|
|
||||||
|
# Bob's membership in two projects counts as one seat,
|
||||||
|
# one seat should be still free:
|
||||||
|
self.assertTrue(self.project.can_invite_new_users())
|
||||||
|
|
||||||
|
def test_it_checks_team_limit(self):
|
||||||
|
p2 = Project.objects.create(owner=self.alice)
|
||||||
|
Member.objects.create(user=self.charlie, project=p2)
|
||||||
|
|
||||||
|
# Alice and Bob are in one project, Charlie is in another,
|
||||||
|
# so no seats left:
|
||||||
|
self.assertFalse(self.project.can_invite_new_users())
|
@ -1,8 +1,10 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from hc.accounts.management.commands.pruneusers import Command
|
from hc.accounts.management.commands.pruneusers import Command
|
||||||
|
from hc.accounts.models import Project
|
||||||
from hc.api.models import Check
|
from hc.api.models import Check
|
||||||
from hc.test import BaseTestCase
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
@ -15,26 +17,20 @@ class PruneUsersTestCase(BaseTestCase):
|
|||||||
self.charlie.save()
|
self.charlie.save()
|
||||||
|
|
||||||
# Charlie has one demo check
|
# Charlie has one demo check
|
||||||
Check(user=self.charlie).save()
|
charlies_project = Project.objects.create(owner=self.charlie)
|
||||||
|
Check(project=charlies_project).save()
|
||||||
|
|
||||||
Command().handle()
|
Command(stdout=Mock()).handle()
|
||||||
|
|
||||||
self.assertEqual(User.objects.filter(username="charlie").count(), 0)
|
self.assertEqual(User.objects.filter(username="charlie").count(), 0)
|
||||||
self.assertEqual(Check.objects.count(), 0)
|
self.assertEqual(Check.objects.count(), 0)
|
||||||
|
|
||||||
def test_it_removes_old_users_with_zero_checks(self):
|
|
||||||
self.charlie.date_joined = self.year_ago
|
|
||||||
self.charlie.last_login = self.year_ago
|
|
||||||
self.charlie.save()
|
|
||||||
|
|
||||||
Command().handle()
|
|
||||||
self.assertEqual(User.objects.filter(username="charlie").count(), 0)
|
|
||||||
|
|
||||||
def test_it_leaves_team_members_alone(self):
|
def test_it_leaves_team_members_alone(self):
|
||||||
self.bob.date_joined = self.year_ago
|
self.bob.date_joined = self.year_ago
|
||||||
self.bob.last_login = self.year_ago
|
self.bob.last_login = self.year_ago
|
||||||
self.bob.save()
|
self.bob.save()
|
||||||
|
|
||||||
Command().handle()
|
Command(stdout=Mock()).handle()
|
||||||
|
|
||||||
# Bob belongs to a team so should not get removed
|
# Bob belongs to a team so should not get removed
|
||||||
self.assertEqual(User.objects.filter(username="bob").count(), 1)
|
self.assertEqual(User.objects.filter(username="bob").count(), 1)
|
||||||
|
56
hc/accounts/tests/test_remote_user_header_login.py
Normal file
56
hc/accounts/tests/test_remote_user_header_login.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
REMOTE_USER_HEADER="AUTH_USER",
|
||||||
|
AUTHENTICATION_BACKENDS=("hc.accounts.backends.CustomHeaderBackend",),
|
||||||
|
)
|
||||||
|
class RemoteUserHeaderTestCase(BaseTestCase):
|
||||||
|
@override_settings(REMOTE_USER_HEADER=None)
|
||||||
|
def test_it_does_nothing_when_not_configured(self):
|
||||||
|
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
|
||||||
|
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
|
||||||
|
|
||||||
|
def test_it_logs_user_in(self):
|
||||||
|
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
|
||||||
|
self.assertContains(r, "alice@example.org")
|
||||||
|
|
||||||
|
def test_it_does_nothing_when_header_not_set(self):
|
||||||
|
r = self.client.get("/accounts/profile/")
|
||||||
|
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
|
||||||
|
|
||||||
|
def test_it_does_nothing_when_header_is_empty_string(self):
|
||||||
|
r = self.client.get("/accounts/profile/", AUTH_USER="")
|
||||||
|
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
|
||||||
|
|
||||||
|
def test_it_creates_user(self):
|
||||||
|
r = self.client.get("/accounts/profile/", AUTH_USER="dave@example.org")
|
||||||
|
self.assertContains(r, "dave@example.org")
|
||||||
|
|
||||||
|
q = User.objects.filter(email="dave@example.org")
|
||||||
|
self.assertTrue(q.exists())
|
||||||
|
|
||||||
|
def test_it_logs_out_another_user_when_header_is_empty_string(self):
|
||||||
|
self.client.login(remote_user_email="bob@example.org")
|
||||||
|
|
||||||
|
r = self.client.get("/accounts/profile/", AUTH_USER="")
|
||||||
|
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
|
||||||
|
|
||||||
|
def test_it_logs_out_another_user(self):
|
||||||
|
self.client.login(remote_user_email="bob@example.org")
|
||||||
|
|
||||||
|
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
|
||||||
|
self.assertContains(r, "alice@example.org")
|
||||||
|
|
||||||
|
def test_it_handles_already_logged_in_user(self):
|
||||||
|
self.client.login(remote_user_email="alice@example.org")
|
||||||
|
|
||||||
|
with patch("hc.accounts.middleware.auth") as mock_auth:
|
||||||
|
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
|
||||||
|
|
||||||
|
self.assertFalse(mock_auth.authenticate.called)
|
||||||
|
self.assertContains(r, "alice@example.org")
|
63
hc/accounts/tests/test_remove_credential.py
Normal file
63
hc/accounts/tests/test_remove_credential.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
from hc.accounts.models import Credential
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(RP_ID="testserver")
|
||||||
|
class RemoveCredentialTestCase(BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.c = Credential.objects.create(user=self.alice, name="Alices Key")
|
||||||
|
self.url = f"/accounts/two_factor/{self.c.code}/remove/"
|
||||||
|
|
||||||
|
def test_it_requires_sudo_mode(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "We have sent a confirmation code")
|
||||||
|
|
||||||
|
@override_settings(RP_ID=None)
|
||||||
|
def test_it_requires_rp_id(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
def test_it_shows_form(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "Remove Security Key")
|
||||||
|
self.assertContains(r, "Alices Key")
|
||||||
|
self.assertContains(r, "two-factor authentication will no longer be active")
|
||||||
|
|
||||||
|
def test_it_skips_warning_when_other_2fa_methods_exist(self):
|
||||||
|
self.profile.totp = "0" * 32
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertNotContains(r, "two-factor authentication will no longer be active")
|
||||||
|
|
||||||
|
def test_it_removes_credential(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
r = self.client.post(self.url, {"remove_credential": ""}, follow=True)
|
||||||
|
self.assertRedirects(r, "/accounts/profile/")
|
||||||
|
self.assertContains(r, "Removed security key <strong>Alices Key</strong>")
|
||||||
|
|
||||||
|
self.assertFalse(self.alice.credentials.exists())
|
||||||
|
|
||||||
|
def test_it_checks_owner(self):
|
||||||
|
self.client.login(username="charlie@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
r = self.client.post(self.url, {"remove_credential": ""})
|
||||||
|
self.assertEqual(r.status_code, 400)
|
32
hc/accounts/tests/test_remove_project.py
Normal file
32
hc/accounts/tests/test_remove_project.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from hc.api.models import Check
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveProjectTestCase(BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.url = "/projects/%s/remove/" % self.project.code
|
||||||
|
|
||||||
|
def test_it_works(self):
|
||||||
|
Check.objects.create(project=self.project, tags="foo a-B_1 baz@")
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.post(self.url)
|
||||||
|
self.assertRedirects(r, "/")
|
||||||
|
|
||||||
|
# Alice should not own any projects
|
||||||
|
self.assertFalse(self.alice.project_set.exists())
|
||||||
|
|
||||||
|
# Check should be gone
|
||||||
|
self.assertFalse(Check.objects.exists())
|
||||||
|
|
||||||
|
def test_it_rejects_get(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertEqual(r.status_code, 405)
|
||||||
|
|
||||||
|
def test_it_checks_access(self):
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
r = self.client.post(self.url)
|
||||||
|
self.assertEqual(r.status_code, 404)
|
46
hc/accounts/tests/test_remove_totp.py
Normal file
46
hc/accounts/tests/test_remove_totp.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
from hc.accounts.models import Credential
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveCredentialTestCase(BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.profile.totp = "0" * 32
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
self.url = "/accounts/two_factor/totp/remove/"
|
||||||
|
|
||||||
|
def test_it_requires_sudo_mode(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "We have sent a confirmation code")
|
||||||
|
|
||||||
|
def test_it_shows_form(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "Disable Authenticator App")
|
||||||
|
self.assertContains(r, "two-factor authentication will no longer be active")
|
||||||
|
|
||||||
|
def test_it_skips_warning_when_other_2fa_methods_exist(self):
|
||||||
|
self.c = Credential.objects.create(user=self.alice, name="Alices Key")
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertNotContains(r, "two-factor authentication will no longer be active")
|
||||||
|
|
||||||
|
def test_it_removes_totp(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
r = self.client.post(self.url, {"disable_totp": "1"}, follow=True)
|
||||||
|
self.assertRedirects(r, "/accounts/profile/")
|
||||||
|
self.assertContains(r, "Disabled the authenticator app.")
|
||||||
|
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
self.assertIsNone(self.profile.totp)
|
||||||
|
self.assertIsNone(self.profile.totp_created)
|
112
hc/accounts/tests/test_senddeletionnotices.py
Normal file
112
hc/accounts/tests/test_senddeletionnotices.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
from datetime import timedelta as td
|
||||||
|
import re
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from django.core import mail
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from hc.accounts.management.commands.senddeletionnotices import Command
|
||||||
|
from hc.accounts.models import Member
|
||||||
|
from hc.api.models import Check, Ping
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
|
def counts(result):
|
||||||
|
""" Extract integer values from command's return value. """
|
||||||
|
return [int(s) for s in re.findall(r"\d+", result)]
|
||||||
|
|
||||||
|
|
||||||
|
class SendDeletionNoticesTestCase(BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
# Make alice eligible for notice -- signed up more than 1 year ago
|
||||||
|
self.alice.date_joined = now() - td(days=500)
|
||||||
|
self.alice.save()
|
||||||
|
|
||||||
|
self.profile.sms_limit = 5
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
# remove members from alice's project
|
||||||
|
self.project.member_set.all().delete()
|
||||||
|
|
||||||
|
def test_it_sends_notice(self):
|
||||||
|
cmd = Command(stdout=Mock())
|
||||||
|
cmd.pause = Mock() # don't pause for 1s
|
||||||
|
|
||||||
|
result = cmd.handle()
|
||||||
|
self.assertEqual(counts(result), [1, 0, 0])
|
||||||
|
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
self.assertTrue(self.profile.deletion_notice_date)
|
||||||
|
|
||||||
|
email = mail.outbox[0]
|
||||||
|
self.assertEqual(email.subject, "Inactive Account Notification")
|
||||||
|
|
||||||
|
def test_it_checks_last_login(self):
|
||||||
|
# alice has logged in recently:
|
||||||
|
self.alice.last_login = now() - td(days=15)
|
||||||
|
self.alice.save()
|
||||||
|
|
||||||
|
result = Command(stdout=Mock()).handle()
|
||||||
|
self.assertEqual(counts(result), [0, 0, 0])
|
||||||
|
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
self.assertIsNone(self.profile.deletion_notice_date)
|
||||||
|
|
||||||
|
def test_it_checks_date_joined(self):
|
||||||
|
# alice signed up recently:
|
||||||
|
self.alice.date_joined = now() - td(days=15)
|
||||||
|
self.alice.save()
|
||||||
|
|
||||||
|
result = Command(stdout=Mock()).handle()
|
||||||
|
self.assertEqual(counts(result), [0, 0, 0])
|
||||||
|
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
self.assertIsNone(self.profile.deletion_notice_date)
|
||||||
|
|
||||||
|
def test_it_checks_deletion_notice_date(self):
|
||||||
|
# alice has already received a deletion notice
|
||||||
|
self.profile.deletion_notice_date = now() - td(days=15)
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
result = Command(stdout=Mock()).handle()
|
||||||
|
self.assertEqual(counts(result), [0, 0, 0])
|
||||||
|
|
||||||
|
def test_it_checks_sms_limit(self):
|
||||||
|
# alice has a paid account
|
||||||
|
self.profile.sms_limit = 50
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
result = Command(stdout=Mock()).handle()
|
||||||
|
self.assertEqual(counts(result), [0, 0, 0])
|
||||||
|
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
self.assertIsNone(self.profile.deletion_notice_date)
|
||||||
|
|
||||||
|
def test_it_checks_team_members(self):
|
||||||
|
# bob has access to alice's project
|
||||||
|
Member.objects.create(user=self.bob, project=self.project)
|
||||||
|
|
||||||
|
result = Command(stdout=Mock()).handle()
|
||||||
|
self.assertEqual(counts(result), [0, 1, 0])
|
||||||
|
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
self.assertIsNone(self.profile.deletion_notice_date)
|
||||||
|
|
||||||
|
def test_it_checks_recent_pings(self):
|
||||||
|
check = Check.objects.create(project=self.project)
|
||||||
|
Ping.objects.create(owner=check)
|
||||||
|
|
||||||
|
result = Command(stdout=Mock()).handle()
|
||||||
|
self.assertEqual(counts(result), [0, 0, 1])
|
||||||
|
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
self.assertIsNone(self.profile.deletion_notice_date)
|
||||||
|
|
||||||
|
def test_it_checks_last_active_date(self):
|
||||||
|
# alice has been browsing the site recently
|
||||||
|
self.profile.last_active_date = now() - td(days=15)
|
||||||
|
self.profile.save()
|
||||||
|
|
||||||
|
result = Command(stdout=Mock()).handle()
|
||||||
|
self.assertEqual(counts(result), [0, 0, 0])
|
40
hc/accounts/tests/test_set_password.py
Normal file
40
hc/accounts/tests/test_set_password.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class SetPasswordTestCase(BaseTestCase):
|
||||||
|
def test_it_requires_sudo_mode(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get("/accounts/set_password/")
|
||||||
|
self.assertContains(r, "We have sent a confirmation code")
|
||||||
|
|
||||||
|
def test_it_shows_form(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
r = self.client.get("/accounts/set_password/")
|
||||||
|
self.assertContains(r, "Please pick a password")
|
||||||
|
|
||||||
|
def test_it_sets_password(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
payload = {"password": "correct horse battery staple"}
|
||||||
|
r = self.client.post("/accounts/set_password/", payload)
|
||||||
|
self.assertRedirects(r, "/accounts/profile/")
|
||||||
|
|
||||||
|
old_password = self.alice.password
|
||||||
|
self.alice.refresh_from_db()
|
||||||
|
self.assertNotEqual(self.alice.password, old_password)
|
||||||
|
|
||||||
|
def test_post_checks_length(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.set_sudo_flag()
|
||||||
|
|
||||||
|
payload = {"password": "abc"}
|
||||||
|
r = self.client.post("/accounts/set_password/", payload)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
old_password = self.alice.password
|
||||||
|
self.alice.refresh_from_db()
|
||||||
|
self.assertEqual(self.alice.password, old_password)
|
104
hc/accounts/tests/test_signup.py
Normal file
104
hc/accounts/tests/test_signup.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.core import mail
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
from hc.accounts.models import Profile, Project
|
||||||
|
from hc.api.models import Channel, Check
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class SignupTestCase(TestCase):
|
||||||
|
@override_settings(USE_PAYMENTS=False)
|
||||||
|
def test_it_works(self):
|
||||||
|
form = {"identity": "alice@example.org", "tz": "Europe/Riga"}
|
||||||
|
|
||||||
|
r = self.client.post("/accounts/signup/", form)
|
||||||
|
self.assertContains(r, "Account created")
|
||||||
|
self.assertIn("auto-login", r.cookies)
|
||||||
|
|
||||||
|
# An user should have been created
|
||||||
|
user = User.objects.get()
|
||||||
|
|
||||||
|
# A profile should have been created
|
||||||
|
profile = Profile.objects.get()
|
||||||
|
self.assertEqual(profile.check_limit, 500)
|
||||||
|
self.assertEqual(profile.sms_limit, 500)
|
||||||
|
self.assertEqual(profile.call_limit, 500)
|
||||||
|
self.assertEqual(profile.tz, "Europe/Riga")
|
||||||
|
|
||||||
|
# And email sent
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
subject = "Log in to %s" % settings.SITE_NAME
|
||||||
|
self.assertEqual(mail.outbox[0].subject, subject)
|
||||||
|
|
||||||
|
# A project should have been created
|
||||||
|
project = Project.objects.get()
|
||||||
|
self.assertEqual(project.owner, user)
|
||||||
|
self.assertEqual(project.badge_key, user.username)
|
||||||
|
|
||||||
|
# And check should be associated with the new user
|
||||||
|
check = Check.objects.get()
|
||||||
|
self.assertEqual(check.name, "My First Check")
|
||||||
|
self.assertEqual(check.project, project)
|
||||||
|
|
||||||
|
# A channel should have been created
|
||||||
|
channel = Channel.objects.get()
|
||||||
|
self.assertEqual(channel.project, project)
|
||||||
|
|
||||||
|
@override_settings(USE_PAYMENTS=True)
|
||||||
|
def test_it_sets_limits(self):
|
||||||
|
form = {"identity": "alice@example.org", "tz": ""}
|
||||||
|
|
||||||
|
self.client.post("/accounts/signup/", form)
|
||||||
|
|
||||||
|
profile = Profile.objects.get()
|
||||||
|
self.assertEqual(profile.check_limit, 20)
|
||||||
|
self.assertEqual(profile.sms_limit, 5)
|
||||||
|
self.assertEqual(profile.call_limit, 0)
|
||||||
|
|
||||||
|
@override_settings(REGISTRATION_OPEN=False)
|
||||||
|
def test_it_obeys_registration_open(self):
|
||||||
|
form = {"identity": "dan@example.org", "tz": ""}
|
||||||
|
|
||||||
|
r = self.client.post("/accounts/signup/", form)
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
|
||||||
|
def test_it_ignores_case(self):
|
||||||
|
form = {"identity": "ALICE@EXAMPLE.ORG", "tz": ""}
|
||||||
|
self.client.post("/accounts/signup/", form)
|
||||||
|
|
||||||
|
# There should be exactly one user:
|
||||||
|
q = User.objects.filter(email="alice@example.org")
|
||||||
|
self.assertTrue(q.exists)
|
||||||
|
|
||||||
|
def test_it_checks_for_existing_users(self):
|
||||||
|
alice = User(username="alice", email="alice@example.org")
|
||||||
|
alice.save()
|
||||||
|
|
||||||
|
form = {"identity": "alice@example.org", "tz": ""}
|
||||||
|
r = self.client.post("/accounts/signup/", form)
|
||||||
|
self.assertContains(r, "already exists")
|
||||||
|
|
||||||
|
def test_it_checks_syntax(self):
|
||||||
|
form = {"identity": "alice at example org", "tz": ""}
|
||||||
|
r = self.client.post("/accounts/signup/", form)
|
||||||
|
self.assertContains(r, "Enter a valid email address")
|
||||||
|
|
||||||
|
def test_it_checks_length(self):
|
||||||
|
aaa = "a" * 300
|
||||||
|
form = {"identity": f"alice+{aaa}@example.org", "tz": ""}
|
||||||
|
r = self.client.post("/accounts/signup/", form)
|
||||||
|
self.assertContains(r, "Address is too long.")
|
||||||
|
|
||||||
|
self.assertFalse(User.objects.exists())
|
||||||
|
|
||||||
|
@override_settings(USE_PAYMENTS=False)
|
||||||
|
def test_it_ignores_bad_tz(self):
|
||||||
|
form = {"identity": "alice@example.org", "tz": "Foo/Bar"}
|
||||||
|
|
||||||
|
r = self.client.post("/accounts/signup/", form)
|
||||||
|
self.assertContains(r, "Account created")
|
||||||
|
self.assertIn("auto-login", r.cookies)
|
||||||
|
|
||||||
|
profile = Profile.objects.get()
|
||||||
|
self.assertEqual(profile.tz, "UTC")
|
73
hc/accounts/tests/test_sudo_mode.py
Normal file
73
hc/accounts/tests/test_sudo_mode.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
from django.core import mail
|
||||||
|
from django.core.signing import TimestampSigner
|
||||||
|
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
from hc.accounts.models import Credential
|
||||||
|
from hc.api.models import TokenBucket
|
||||||
|
|
||||||
|
|
||||||
|
class SudoModeTestCase(BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.c = Credential.objects.create(user=self.alice, name="Alices Key")
|
||||||
|
self.url = f"/accounts/set_password/"
|
||||||
|
|
||||||
|
def test_it_sends_code(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "We have sent a confirmation code")
|
||||||
|
|
||||||
|
# A code should have been sent
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
|
||||||
|
email = mail.outbox[0]
|
||||||
|
self.assertEqual(email.to[0], "alice@example.org")
|
||||||
|
self.assertIn("Confirmation code", email.subject)
|
||||||
|
|
||||||
|
def test_it_accepts_code(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
session = self.client.session
|
||||||
|
session["sudo_code"] = TimestampSigner().sign("123456")
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
r = self.client.post(self.url, {"sudo_code": "123456"})
|
||||||
|
self.assertRedirects(r, self.url)
|
||||||
|
|
||||||
|
# sudo mode should now be active
|
||||||
|
self.assertIn("sudo", self.client.session)
|
||||||
|
|
||||||
|
def test_it_rejects_incorrect_code(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
session = self.client.session
|
||||||
|
session["sudo_code"] = TimestampSigner().sign("123456")
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
r = self.client.post(self.url, {"sudo_code": "000000"})
|
||||||
|
self.assertContains(r, "Not a valid code.")
|
||||||
|
|
||||||
|
# sudo mode should *not* be active
|
||||||
|
self.assertNotIn("sudo", self.client.session)
|
||||||
|
|
||||||
|
def test_it_passes_through_if_sudo_mode_is_active(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
session = self.client.session
|
||||||
|
session["sudo"] = TimestampSigner().sign("active")
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "Please pick a password")
|
||||||
|
|
||||||
|
def test_it_uses_rate_limiting(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
obj = TokenBucket(value=f"sudo-{self.alice.id}")
|
||||||
|
obj.tokens = 0
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "Too Many Requests")
|
@ -1,44 +0,0 @@
|
|||||||
from hc.test import BaseTestCase
|
|
||||||
from hc.api.models import Check
|
|
||||||
|
|
||||||
|
|
||||||
class SwitchTeamTestCase(BaseTestCase):
|
|
||||||
|
|
||||||
def test_it_switches(self):
|
|
||||||
c = Check(user=self.alice, name="This belongs to Alice")
|
|
||||||
c.save()
|
|
||||||
|
|
||||||
self.client.login(username="bob@example.org", password="password")
|
|
||||||
|
|
||||||
url = "/accounts/switch_team/%s/" % self.alice.username
|
|
||||||
r = self.client.get(url, follow=True)
|
|
||||||
|
|
||||||
self.assertContains(r, "This belongs to Alice")
|
|
||||||
|
|
||||||
def test_it_checks_team_membership(self):
|
|
||||||
self.client.login(username="charlie@example.org", password="password")
|
|
||||||
|
|
||||||
url = "/accounts/switch_team/%s/" % self.alice.username
|
|
||||||
r = self.client.get(url)
|
|
||||||
self.assertEqual(r.status_code, 403)
|
|
||||||
|
|
||||||
def test_it_switches_to_own_team(self):
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
|
||||||
|
|
||||||
url = "/accounts/switch_team/%s/" % self.alice.username
|
|
||||||
r = self.client.get(url, follow=True)
|
|
||||||
self.assertEqual(r.status_code, 200)
|
|
||||||
|
|
||||||
def test_it_handles_invalid_username(self):
|
|
||||||
self.client.login(username="bob@example.org", password="password")
|
|
||||||
|
|
||||||
url = "/accounts/switch_team/dave/"
|
|
||||||
r = self.client.get(url)
|
|
||||||
self.assertEqual(r.status_code, 403)
|
|
||||||
|
|
||||||
def test_it_requires_login(self):
|
|
||||||
url = "/accounts/switch_team/%s/" % self.alice.username
|
|
||||||
r = self.client.get(url)
|
|
||||||
|
|
||||||
expected_url = "/accounts/login/?next=/accounts/switch_team/alice/"
|
|
||||||
self.assertRedirects(r, expected_url)
|
|
@ -4,7 +4,6 @@ from hc.accounts.models import Profile
|
|||||||
|
|
||||||
|
|
||||||
class TeamAccessMiddlewareTestCase(TestCase):
|
class TeamAccessMiddlewareTestCase(TestCase):
|
||||||
|
|
||||||
def test_it_handles_missing_profile(self):
|
def test_it_handles_missing_profile(self):
|
||||||
user = User(username="ned", email="ned@example.org")
|
user = User(username="ned", email="ned@example.org")
|
||||||
user.set_password("password")
|
user.set_password("password")
|
||||||
|
169
hc/accounts/tests/test_transfer_project.py
Normal file
169
hc/accounts/tests/test_transfer_project.py
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
from django.core import mail
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from hc.accounts.models import Member
|
||||||
|
from hc.api.models import Check
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TransferProjectTestCase(BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
Check.objects.create(project=self.project)
|
||||||
|
|
||||||
|
self.url = "/projects/%s/settings/" % self.project.code
|
||||||
|
|
||||||
|
def test_transfer_project_works(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"transfer_project": "1", "email": "bob@example.org"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertContains(r, "Transfer initiated!")
|
||||||
|
|
||||||
|
self.bobs_membership.refresh_from_db()
|
||||||
|
self.assertIsNotNone(self.bobs_membership.transfer_request_date)
|
||||||
|
|
||||||
|
# Bob should receive an email notification
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
body = mail.outbox[0].body
|
||||||
|
self.assertTrue("/?next=" + self.url in body)
|
||||||
|
|
||||||
|
def test_transfer_project_checks_ownership(self):
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"transfer_project": "1", "email": "bob@example.org"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
|
||||||
|
def test_transfer_project_checks_membership(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
form = {"transfer_project": "1", "email": "charlie@example.org"}
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
def test_cancel_works(self):
|
||||||
|
self.bobs_membership.transfer_request_date = now()
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"cancel_transfer": "1"})
|
||||||
|
self.assertContains(r, "Transfer cancelled!")
|
||||||
|
|
||||||
|
self.bobs_membership.refresh_from_db()
|
||||||
|
self.assertIsNone(self.bobs_membership.transfer_request_date)
|
||||||
|
|
||||||
|
def test_cancel_checks_ownership(self):
|
||||||
|
self.bobs_membership.transfer_request_date = now()
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"cancel_transfer": "1"})
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
|
||||||
|
self.bobs_membership.refresh_from_db()
|
||||||
|
self.assertIsNotNone(self.bobs_membership.transfer_request_date)
|
||||||
|
|
||||||
|
def test_it_shows_transfer_request(self):
|
||||||
|
self.bobs_membership.transfer_request_date = now()
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "would like to transfer")
|
||||||
|
self.assertNotContains(r, "upgrade your account first")
|
||||||
|
|
||||||
|
def test_it_shows_transfer_request_with_limit_notice(self):
|
||||||
|
self.bobs_membership.transfer_request_date = now()
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.bobs_profile.check_limit = 0
|
||||||
|
self.bobs_profile.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "upgrade your account first")
|
||||||
|
|
||||||
|
def test_accept_works(self):
|
||||||
|
self.bobs_membership.transfer_request_date = now()
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"accept_transfer": "1"})
|
||||||
|
self.assertContains(r, "You are now the owner of this project!")
|
||||||
|
|
||||||
|
self.project.refresh_from_db()
|
||||||
|
# Bob should now be the owner
|
||||||
|
self.assertEqual(self.project.owner, self.bob)
|
||||||
|
|
||||||
|
# Alice, the previous owner, should now be a member
|
||||||
|
m = Member.objects.get(project=self.project, user=self.alice)
|
||||||
|
self.assertEqual(m.role, Member.Role.REGULAR)
|
||||||
|
|
||||||
|
def test_accept_requires_a_transfer_request(self):
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"accept_transfer": "1"})
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
|
||||||
|
self.project.refresh_from_db()
|
||||||
|
# Alice should still be the owner
|
||||||
|
self.assertEqual(self.project.owner, self.alice)
|
||||||
|
|
||||||
|
def test_only_the_proposed_owner_can_accept(self):
|
||||||
|
self.bobs_membership.transfer_request_date = now()
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"accept_transfer": "1"})
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
|
||||||
|
def test_it_checks_limits(self):
|
||||||
|
self.bobs_membership.transfer_request_date = now()
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.bobs_profile.check_limit = 0
|
||||||
|
self.bobs_profile.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"accept_transfer": "1"})
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
def test_reject_works(self):
|
||||||
|
self.bobs_membership.transfer_request_date = now()
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"reject_transfer": "1"})
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
self.project.refresh_from_db()
|
||||||
|
# Alice should still be the owner
|
||||||
|
self.assertEqual(self.project.owner, self.alice)
|
||||||
|
|
||||||
|
# The transfer_request_date should be cleared out
|
||||||
|
self.bobs_membership.refresh_from_db()
|
||||||
|
self.assertIsNone(self.bobs_membership.transfer_request_date)
|
||||||
|
|
||||||
|
def test_only_the_proposed_owner_can_reject(self):
|
||||||
|
self.bobs_membership.transfer_request_date = now()
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"reject_transfer": "1"})
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
|
||||||
|
def test_readonly_user_can_accept(self):
|
||||||
|
self.bobs_membership.transfer_request_date = now()
|
||||||
|
self.bobs_membership.role = "r"
|
||||||
|
self.bobs_membership.save()
|
||||||
|
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
self.client.post(self.url, {"accept_transfer": "1"})
|
||||||
|
|
||||||
|
self.project.refresh_from_db()
|
||||||
|
# Bob should now be the owner
|
||||||
|
self.assertEqual(self.project.owner, self.bob)
|
||||||
|
|
||||||
|
# Alice, the previous owner, should now be a *regular* member
|
||||||
|
m = Member.objects.get(user=self.alice, project=self.project)
|
||||||
|
self.assertEqual(m.role, "w")
|
@ -1,4 +1,6 @@
|
|||||||
from datetime import timedelta as td
|
from datetime import timedelta as td
|
||||||
|
import time
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
@ -6,40 +8,55 @@ from hc.test import BaseTestCase
|
|||||||
|
|
||||||
|
|
||||||
class UnsubscribeReportsTestCase(BaseTestCase):
|
class UnsubscribeReportsTestCase(BaseTestCase):
|
||||||
|
def test_it_unsubscribes(self):
|
||||||
def test_token_works(self):
|
|
||||||
self.profile.next_report_date = now()
|
self.profile.next_report_date = now()
|
||||||
self.profile.nag_period = td(hours=1)
|
self.profile.nag_period = td(hours=1)
|
||||||
self.profile.next_nag_date = now()
|
self.profile.next_nag_date = now()
|
||||||
self.profile.save()
|
self.profile.save()
|
||||||
|
|
||||||
token = signing.Signer().sign("foo")
|
sig = signing.TimestampSigner(salt="reports").sign("alice")
|
||||||
url = "/accounts/unsubscribe_reports/alice/?token=%s" % token
|
url = "/accounts/unsubscribe_reports/%s/" % sig
|
||||||
r = self.client.get(url)
|
|
||||||
self.assertContains(r, "You have been unsubscribed")
|
r = self.client.post(url)
|
||||||
|
self.assertContains(r, "Unsubscribed")
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
self.assertFalse(self.profile.reports_allowed)
|
self.assertEqual(self.profile.reports, "off")
|
||||||
self.assertIsNone(self.profile.next_report_date)
|
self.assertIsNone(self.profile.next_report_date)
|
||||||
|
|
||||||
self.assertEqual(self.profile.nag_period.total_seconds(), 0)
|
self.assertEqual(self.profile.nag_period.total_seconds(), 0)
|
||||||
self.assertIsNone(self.profile.next_nag_date)
|
self.assertIsNone(self.profile.next_nag_date)
|
||||||
|
|
||||||
def test_bad_token_gets_rejected(self):
|
|
||||||
url = "/accounts/unsubscribe_reports/alice/?token=invalid"
|
|
||||||
r = self.client.get(url)
|
|
||||||
self.assertContains(r, "Incorrect Link")
|
|
||||||
|
|
||||||
def test_signed_username_works(self):
|
|
||||||
sig = signing.TimestampSigner(salt="reports").sign("alice")
|
|
||||||
url = "/accounts/unsubscribe_reports/%s/" % sig
|
|
||||||
r = self.client.get(url)
|
|
||||||
self.assertContains(r, "You have been unsubscribed")
|
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
|
||||||
self.assertFalse(self.profile.reports_allowed)
|
|
||||||
|
|
||||||
def test_bad_signature_gets_rejected(self):
|
def test_bad_signature_gets_rejected(self):
|
||||||
url = "/accounts/unsubscribe_reports/invalid/"
|
url = "/accounts/unsubscribe_reports/invalid/"
|
||||||
r = self.client.get(url)
|
r = self.client.get(url)
|
||||||
self.assertContains(r, "Incorrect Link")
|
self.assertContains(r, "Incorrect Link")
|
||||||
|
|
||||||
|
def test_it_serves_confirmation_form(self):
|
||||||
|
sig = signing.TimestampSigner(salt="reports").sign("alice")
|
||||||
|
url = "/accounts/unsubscribe_reports/%s/" % sig
|
||||||
|
|
||||||
|
r = self.client.get(url)
|
||||||
|
self.assertContains(r, "Please press the button below")
|
||||||
|
self.assertNotContains(r, "submit()")
|
||||||
|
|
||||||
|
def test_aged_signature_autosubmits(self):
|
||||||
|
with patch("django.core.signing.time") as mock_time:
|
||||||
|
mock_time.time.return_value = time.time() - 301
|
||||||
|
signer = signing.TimestampSigner(salt="reports")
|
||||||
|
sig = signer.sign("alice")
|
||||||
|
|
||||||
|
url = "/accounts/unsubscribe_reports/%s/" % sig
|
||||||
|
|
||||||
|
r = self.client.get(url)
|
||||||
|
self.assertContains(r, "Please press the button below")
|
||||||
|
self.assertContains(r, "submit()")
|
||||||
|
|
||||||
|
def test_it_handles_missing_user(self):
|
||||||
|
self.alice.delete()
|
||||||
|
|
||||||
|
sig = signing.TimestampSigner(salt="reports").sign("alice")
|
||||||
|
url = "/accounts/unsubscribe_reports/%s/" % sig
|
||||||
|
|
||||||
|
r = self.client.post(url)
|
||||||
|
self.assertContains(r, "Unsubscribed")
|
||||||
|
@ -1,36 +1,36 @@
|
|||||||
from django.conf.urls import url
|
from django.urls import path
|
||||||
from hc.accounts import views
|
from hc.accounts import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^login/$', views.login, name="hc-login"),
|
path("login/", views.login, name="hc-login"),
|
||||||
url(r'^logout/$', views.logout, name="hc-logout"),
|
path("login/two_factor/", views.login_webauthn, name="hc-login-webauthn"),
|
||||||
url(r'^login_link_sent/$',
|
path("login/two_factor/totp/", views.login_totp, name="hc-login-totp"),
|
||||||
views.login_link_sent, name="hc-login-link-sent"),
|
path("logout/", views.logout, name="hc-logout"),
|
||||||
|
path("signup/", views.signup, name="hc-signup"),
|
||||||
url(r'^link_sent/$',
|
path("login_link_sent/", views.login_link_sent, name="hc-login-link-sent"),
|
||||||
views.link_sent, name="hc-link-sent"),
|
path(
|
||||||
|
"check_token/<slug:username>/<slug:token>/",
|
||||||
url(r'^check_token/([\w-]+)/([\w-]+)/$',
|
views.check_token,
|
||||||
views.check_token, name="hc-check-token"),
|
name="hc-check-token",
|
||||||
|
),
|
||||||
url(r'^profile/$', views.profile, name="hc-profile"),
|
path("profile/", views.profile, name="hc-profile"),
|
||||||
url(r'^profile/notifications/$', views.notifications, name="hc-notifications"),
|
path("profile/appearance/", views.appearance, name="hc-appearance"),
|
||||||
url(r'^profile/badges/$', views.badges, name="hc-badges"),
|
path("profile/notifications/", views.notifications, name="hc-notifications"),
|
||||||
url(r'^close/$', views.close, name="hc-close"),
|
path("close/", views.close, name="hc-close"),
|
||||||
|
path(
|
||||||
url(r'^unsubscribe_reports/([\w\:-]+)/$',
|
"unsubscribe_reports/<str:signed_username>/",
|
||||||
views.unsubscribe_reports, name="hc-unsubscribe-reports"),
|
views.unsubscribe_reports,
|
||||||
|
name="hc-unsubscribe-reports",
|
||||||
url(r'^set_password/([\w-]+)/$',
|
),
|
||||||
views.set_password, name="hc-set-password"),
|
path("set_password/", views.set_password, name="hc-set-password"),
|
||||||
|
path("change_email/done/", views.change_email_done, name="hc-change-email-done"),
|
||||||
url(r'^change_email/done/$',
|
path("change_email/", views.change_email, name="hc-change-email"),
|
||||||
views.change_email_done, name="hc-change-email-done"),
|
path("two_factor/webauthn/", views.add_webauthn, name="hc-add-webauthn"),
|
||||||
|
path("two_factor/totp/", views.add_totp, name="hc-add-totp"),
|
||||||
url(r'^change_email/([\w-]+)/$',
|
path("two_factor/totp/remove/", views.remove_totp, name="hc-remove-totp"),
|
||||||
views.change_email, name="hc-change-email"),
|
path(
|
||||||
|
"two_factor/<uuid:code>/remove/",
|
||||||
url(r'^switch_team/([\w-]+)/$',
|
views.remove_credential,
|
||||||
views.switch_team, name="hc-switch-team"),
|
name="hc-remove-credential",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
File diff suppressed because it is too large
Load Diff
197
hc/api/admin.py
197
hc/api/admin.py
@ -1,50 +1,61 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
|
from django.db.models import F
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from hc.api.models import Channel, Check, Notification, Ping
|
from hc.api.models import Channel, Check, Flip, Notification, Ping
|
||||||
from hc.lib.date import format_duration
|
from hc.lib.date import format_duration
|
||||||
|
|
||||||
|
|
||||||
class OwnershipListFilter(admin.SimpleListFilter):
|
|
||||||
title = "Ownership"
|
|
||||||
parameter_name = 'ownership'
|
|
||||||
|
|
||||||
def lookups(self, request, model_admin):
|
|
||||||
return (
|
|
||||||
('assigned', "Assigned"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def queryset(self, request, queryset):
|
|
||||||
if self.value() == 'assigned':
|
|
||||||
return queryset.filter(user__isnull=False)
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Check)
|
@admin.register(Check)
|
||||||
class ChecksAdmin(admin.ModelAdmin):
|
class ChecksAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
css = {
|
css = {"all": ("css/admin/checks.css",)}
|
||||||
'all': ('css/admin/checks.css',)
|
|
||||||
}
|
search_fields = ["name", "code", "project__owner__email"]
|
||||||
|
raw_id_fields = ("project",)
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"name_tags",
|
||||||
|
"project_",
|
||||||
|
"created",
|
||||||
|
"n_pings",
|
||||||
|
"timeout_schedule",
|
||||||
|
"status",
|
||||||
|
"last_start",
|
||||||
|
"last_ping",
|
||||||
|
)
|
||||||
|
list_filter = ("status", "kind", "last_ping", "last_start")
|
||||||
|
|
||||||
search_fields = ["name", "user__email", "code"]
|
|
||||||
list_display = ("id", "name_tags", "created", "code", "timeout_schedule",
|
|
||||||
"status", "email", "last_ping", "n_pings")
|
|
||||||
list_select_related = ("user", )
|
|
||||||
list_filter = ("status", OwnershipListFilter, "kind", "last_ping")
|
|
||||||
actions = ["send_alert"]
|
actions = ["send_alert"]
|
||||||
|
|
||||||
def email(self, obj):
|
def get_queryset(self, request):
|
||||||
return obj.user.email if obj.user else None
|
qs = super().get_queryset(request)
|
||||||
|
qs = qs.annotate(email=F("project__owner__email"))
|
||||||
|
qs = qs.annotate(project_name=F("project__name"))
|
||||||
|
return qs
|
||||||
|
|
||||||
|
@mark_safe
|
||||||
|
def project_(self, obj):
|
||||||
|
url = reverse("hc-checks", args=[obj.project.code])
|
||||||
|
name = escape(obj.project_name or "Default")
|
||||||
|
email = escape(obj.email)
|
||||||
|
return f'{email} › <a href="{url}"">{name}</a>'
|
||||||
|
|
||||||
|
@mark_safe
|
||||||
def name_tags(self, obj):
|
def name_tags(self, obj):
|
||||||
if not obj.tags:
|
url = reverse("hc-details", args=[obj.code])
|
||||||
return obj.name
|
name = escape(obj.name or "unnamed")
|
||||||
|
|
||||||
return "%s [%s]" % (obj.name, obj.tags)
|
s = f'<a href="{url}"">{name}</a>'
|
||||||
|
for tag in obj.tags_list():
|
||||||
|
s += " <span>%s</span>" % escape(tag)
|
||||||
|
|
||||||
|
return s
|
||||||
|
|
||||||
|
@admin.display(description="Schedule")
|
||||||
def timeout_schedule(self, obj):
|
def timeout_schedule(self, obj):
|
||||||
if obj.kind == "simple":
|
if obj.kind == "simple":
|
||||||
return format_duration(obj.timeout)
|
return format_duration(obj.timeout)
|
||||||
@ -53,27 +64,21 @@ class ChecksAdmin(admin.ModelAdmin):
|
|||||||
else:
|
else:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
|
|
||||||
timeout_schedule.short_description = "Schedule"
|
@admin.action(description="Send Alert")
|
||||||
|
|
||||||
def send_alert(self, request, qs):
|
def send_alert(self, request, qs):
|
||||||
for check in qs:
|
for check in qs:
|
||||||
check.send_alert()
|
for channel in check.channel_set.all():
|
||||||
|
channel.notify(check)
|
||||||
|
|
||||||
self.message_user(request, "%d alert(s) sent" % qs.count())
|
self.message_user(request, "%d alert(s) sent" % qs.count())
|
||||||
|
|
||||||
send_alert.short_description = "Send Alert"
|
|
||||||
|
|
||||||
|
|
||||||
class SchemeListFilter(admin.SimpleListFilter):
|
class SchemeListFilter(admin.SimpleListFilter):
|
||||||
title = "Scheme"
|
title = "Scheme"
|
||||||
parameter_name = 'scheme'
|
parameter_name = "scheme"
|
||||||
|
|
||||||
def lookups(self, request, model_admin):
|
def lookups(self, request, model_admin):
|
||||||
return (
|
return (("http", "HTTP"), ("https", "HTTPS"), ("email", "Email"))
|
||||||
('http', "HTTP"),
|
|
||||||
('https', "HTTPS"),
|
|
||||||
('email', "Email"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def queryset(self, request, queryset):
|
def queryset(self, request, queryset):
|
||||||
if self.value():
|
if self.value():
|
||||||
@ -83,7 +88,7 @@ class SchemeListFilter(admin.SimpleListFilter):
|
|||||||
|
|
||||||
class MethodListFilter(admin.SimpleListFilter):
|
class MethodListFilter(admin.SimpleListFilter):
|
||||||
title = "Method"
|
title = "Method"
|
||||||
parameter_name = 'method'
|
parameter_name = "method"
|
||||||
methods = ["HEAD", "GET", "POST", "PUT", "DELETE"]
|
methods = ["HEAD", "GET", "POST", "PUT", "DELETE"]
|
||||||
|
|
||||||
def lookups(self, request, model_admin):
|
def lookups(self, request, model_admin):
|
||||||
@ -95,6 +100,20 @@ class MethodListFilter(admin.SimpleListFilter):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class KindListFilter(admin.SimpleListFilter):
|
||||||
|
title = "Kind"
|
||||||
|
parameter_name = "kind"
|
||||||
|
kinds = ["start", "fail"]
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
return zip(self.kinds, self.kinds)
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if self.value():
|
||||||
|
queryset = queryset.filter(kind=self.value())
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
# Adapted from: https://djangosnippets.org/snippets/2593/
|
# Adapted from: https://djangosnippets.org/snippets/2593/
|
||||||
class LargeTablePaginator(Paginator):
|
class LargeTablePaginator(Paginator):
|
||||||
""" Overrides the count method to get an estimate instead of actual count
|
""" Overrides the count method to get an estimate instead of actual count
|
||||||
@ -104,8 +123,10 @@ class LargeTablePaginator(Paginator):
|
|||||||
def _get_estimate(self):
|
def _get_estimate(self):
|
||||||
try:
|
try:
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor()
|
||||||
cursor.execute("SELECT reltuples FROM pg_class WHERE relname = %s",
|
cursor.execute(
|
||||||
[self.object_list.query.model._meta.db_table])
|
"SELECT reltuples FROM pg_class WHERE relname = %s",
|
||||||
|
[self.object_list.query.model._meta.db_table],
|
||||||
|
)
|
||||||
return int(cursor.fetchone()[0])
|
return int(cursor.fetchone()[0])
|
||||||
except:
|
except:
|
||||||
return 0
|
return 0
|
||||||
@ -130,70 +151,84 @@ class LargeTablePaginator(Paginator):
|
|||||||
# (i.e. is of type list).
|
# (i.e. is of type list).
|
||||||
self._count = len(self.object_list)
|
self._count = len(self.object_list)
|
||||||
return self._count
|
return self._count
|
||||||
|
|
||||||
count = property(_get_count)
|
count = property(_get_count)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Ping)
|
@admin.register(Ping)
|
||||||
class PingsAdmin(admin.ModelAdmin):
|
class PingsAdmin(admin.ModelAdmin):
|
||||||
search_fields = ("owner__name", "owner__code", "owner__user__email")
|
search_fields = ("owner__name", "owner__code")
|
||||||
list_select_related = ("owner", "owner__user")
|
readonly_fields = ("owner",)
|
||||||
list_display = ("id", "created", "check_name", "email", "scheme", "method",
|
list_select_related = ("owner",)
|
||||||
"ua")
|
list_display = ("id", "created", "owner", "scheme", "method", "ua")
|
||||||
list_filter = ("created", SchemeListFilter, MethodListFilter)
|
list_filter = ("created", SchemeListFilter, MethodListFilter, KindListFilter)
|
||||||
|
|
||||||
paginator = LargeTablePaginator
|
paginator = LargeTablePaginator
|
||||||
|
show_full_result_count = False
|
||||||
def check_name(self, obj):
|
|
||||||
return obj.owner.name if obj.owner.name else obj.owner.code
|
|
||||||
|
|
||||||
def email(self, obj):
|
|
||||||
return obj.owner.user.email if obj.owner.user else None
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Channel)
|
@admin.register(Channel)
|
||||||
class ChannelsAdmin(admin.ModelAdmin):
|
class ChannelsAdmin(admin.ModelAdmin):
|
||||||
class Media:
|
class Media:
|
||||||
css = {
|
css = {"all": ("css/admin/channels.css",)}
|
||||||
'all': ('css/admin/channels.css',)
|
|
||||||
}
|
|
||||||
|
|
||||||
search_fields = ["value", "user__email"]
|
search_fields = ["value", "project__owner__email"]
|
||||||
list_select_related = ("user", )
|
list_display = ("id", "transport", "name", "project_", "value", "ok")
|
||||||
list_display = ("id", "code", "email", "formatted_kind", "value",
|
|
||||||
"num_notifications")
|
|
||||||
list_filter = ("kind",)
|
list_filter = ("kind",)
|
||||||
raw_id_fields = ("user", "checks", )
|
raw_id_fields = ("project", "checks")
|
||||||
|
|
||||||
def email(self, obj):
|
|
||||||
return obj.user.email if obj.user else None
|
|
||||||
|
|
||||||
@mark_safe
|
@mark_safe
|
||||||
def formatted_kind(self, obj):
|
def project_(self, obj):
|
||||||
|
url = reverse("hc-checks", args=[obj.project_code])
|
||||||
|
name = escape(obj.project_name or "Default")
|
||||||
|
email = escape(obj.email)
|
||||||
|
return f"{email} › <a href='{url}'>{name}</a>"
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
qs = qs.annotate(project_code=F("project__code"))
|
||||||
|
qs = qs.annotate(project_name=F("project__name"))
|
||||||
|
qs = qs.annotate(email=F("project__owner__email"))
|
||||||
|
return qs
|
||||||
|
|
||||||
|
@mark_safe
|
||||||
|
def transport(self, obj):
|
||||||
|
note = ""
|
||||||
if obj.kind == "email" and not obj.email_verified:
|
if obj.kind == "email" and not obj.email_verified:
|
||||||
return "Email <i>(unconfirmed)</i>"
|
note = " (not verified)"
|
||||||
|
|
||||||
return obj.get_kind_display()
|
return f'<span class="ic-{ obj.kind }"></span> {obj.kind}{note}'
|
||||||
|
|
||||||
formatted_kind.short_description = "Kind"
|
@admin.display(boolean=True)
|
||||||
|
def ok(self, obj):
|
||||||
def num_notifications(self, obj):
|
return False if obj.last_error else True
|
||||||
return Notification.objects.filter(channel=obj).count()
|
|
||||||
|
|
||||||
num_notifications.short_description = "# Notifications"
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Notification)
|
@admin.register(Notification)
|
||||||
class NotificationsAdmin(admin.ModelAdmin):
|
class NotificationsAdmin(admin.ModelAdmin):
|
||||||
search_fields = ["owner__name", "owner__code", "channel__value"]
|
search_fields = ["owner__name", "owner__code", "channel__value", "error"]
|
||||||
|
readonly_fields = ("owner",)
|
||||||
list_select_related = ("owner", "channel")
|
list_select_related = ("owner", "channel")
|
||||||
list_display = ("id", "created", "check_status", "check_name",
|
list_display = (
|
||||||
"channel_kind", "channel_value")
|
"id",
|
||||||
|
"created",
|
||||||
|
"check_status",
|
||||||
|
"owner",
|
||||||
|
"channel_kind",
|
||||||
|
"channel_value",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
list_filter = ("created", "check_status", "channel__kind")
|
list_filter = ("created", "check_status", "channel__kind")
|
||||||
|
raw_id_fields = ("channel",)
|
||||||
def check_name(self, obj):
|
|
||||||
return obj.owner.name_then_code()
|
|
||||||
|
|
||||||
def channel_kind(self, obj):
|
def channel_kind(self, obj):
|
||||||
return obj.channel.kind
|
return obj.channel.kind
|
||||||
|
|
||||||
def channel_value(self, obj):
|
def channel_value(self, obj):
|
||||||
return obj.channel.value
|
return obj.channel.value
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Flip)
|
||||||
|
class FlipsAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "created", "processed", "owner", "old_status", "new_status")
|
||||||
|
raw_id_fields = ("owner",)
|
||||||
|
@ -1,72 +1,117 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.db.models import Q
|
||||||
from django.http import (HttpResponseBadRequest, HttpResponseForbidden,
|
from django.http import HttpResponse, JsonResponse
|
||||||
JsonResponse)
|
from hc.accounts.models import Project
|
||||||
from hc.lib.jsonschema import ValidationError, validate
|
from hc.lib.jsonschema import ValidationError, validate
|
||||||
|
|
||||||
RE_UUID = re.compile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
|
|
||||||
|
def error(msg, status=400):
|
||||||
|
return JsonResponse({"error": msg}, status=status)
|
||||||
|
|
||||||
|
|
||||||
def uuid_or_400(f):
|
def authorize(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def wrapper(request, *args, **kwds):
|
def wrapper(request, *args, **kwds):
|
||||||
if not RE_UUID.match(args[0]):
|
|
||||||
return HttpResponseBadRequest()
|
|
||||||
|
|
||||||
return f(request, *args, **kwds)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
def make_error(msg):
|
|
||||||
return JsonResponse({"error": msg}, status=400)
|
|
||||||
|
|
||||||
|
|
||||||
def check_api_key(f):
|
|
||||||
@wraps(f)
|
|
||||||
def wrapper(request, *args, **kwds):
|
|
||||||
request.json = {}
|
|
||||||
if request.body:
|
|
||||||
try:
|
|
||||||
request.json = json.loads(request.body.decode("utf-8"))
|
|
||||||
except ValueError:
|
|
||||||
return make_error("could not parse request body")
|
|
||||||
|
|
||||||
if "HTTP_X_API_KEY" in request.META:
|
if "HTTP_X_API_KEY" in request.META:
|
||||||
api_key = request.META["HTTP_X_API_KEY"]
|
api_key = request.META["HTTP_X_API_KEY"]
|
||||||
else:
|
else:
|
||||||
api_key = request.json.get("api_key", "")
|
api_key = str(request.json.get("api_key", ""))
|
||||||
|
|
||||||
if api_key == "":
|
if len(api_key) != 32:
|
||||||
return make_error("wrong api_key")
|
return error("missing api key", 401)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request.user = User.objects.get(profile__api_key=api_key)
|
request.project = Project.objects.get(api_key=api_key)
|
||||||
except User.DoesNotExist:
|
except Project.DoesNotExist:
|
||||||
return HttpResponseForbidden()
|
return error("wrong api key", 401)
|
||||||
|
|
||||||
|
request.readonly = False
|
||||||
return f(request, *args, **kwds)
|
return f(request, *args, **kwds)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
def validate_json(schema):
|
def authorize_read(f):
|
||||||
""" Validate request.json contents against `schema`.
|
@wraps(f)
|
||||||
|
def wrapper(request, *args, **kwds):
|
||||||
|
if "HTTP_X_API_KEY" in request.META:
|
||||||
|
api_key = request.META["HTTP_X_API_KEY"]
|
||||||
|
else:
|
||||||
|
api_key = str(request.json.get("api_key", ""))
|
||||||
|
|
||||||
Supports a tiny subset of JSON schema spec.
|
if len(api_key) != 32:
|
||||||
|
return error("missing api key", 401)
|
||||||
|
|
||||||
|
write_key_match = Q(api_key=api_key)
|
||||||
|
read_key_match = Q(api_key_readonly=api_key)
|
||||||
|
try:
|
||||||
|
request.project = Project.objects.get(write_key_match | read_key_match)
|
||||||
|
except Project.DoesNotExist:
|
||||||
|
return error("wrong api key", 401)
|
||||||
|
|
||||||
|
request.readonly = api_key == request.project.api_key_readonly
|
||||||
|
return f(request, *args, **kwds)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def validate_json(schema=None):
|
||||||
|
""" Parse request json and validate it against `schema`.
|
||||||
|
|
||||||
|
Put the parsed result in `request.json`.
|
||||||
|
If schema is None then only parse and don't validate.
|
||||||
|
Supports a limited subset of JSON schema spec.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(f):
|
def decorator(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def wrapper(request, *args, **kwds):
|
def wrapper(request, *args, **kwds):
|
||||||
|
if request.body:
|
||||||
|
try:
|
||||||
|
request.json = json.loads(request.body.decode())
|
||||||
|
except ValueError:
|
||||||
|
return error("could not parse request body")
|
||||||
|
else:
|
||||||
|
request.json = {}
|
||||||
|
|
||||||
|
if schema:
|
||||||
try:
|
try:
|
||||||
validate(request.json, schema)
|
validate(request.json, schema)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return make_error("json validation error: %s" % e)
|
return error("json validation error: %s" % e)
|
||||||
|
|
||||||
return f(request, *args, **kwds)
|
return f(request, *args, **kwds)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def cors(*methods):
|
||||||
|
methods = set(methods)
|
||||||
|
methods.add("OPTIONS")
|
||||||
|
methods_str = ", ".join(methods)
|
||||||
|
|
||||||
|
def decorator(f):
|
||||||
|
@wraps(f)
|
||||||
|
def wrapper(request, *args, **kwds):
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
# Handle OPTIONS here
|
||||||
|
response = HttpResponse(status=204)
|
||||||
|
elif request.method in methods:
|
||||||
|
response = f(request, *args, **kwds)
|
||||||
|
else:
|
||||||
|
response = HttpResponse(status=405)
|
||||||
|
|
||||||
|
response["Access-Control-Allow-Origin"] = "*"
|
||||||
|
response["Access-Control-Allow-Headers"] = "X-Api-Key"
|
||||||
|
response["Access-Control-Allow-Methods"] = methods_str
|
||||||
|
response["Access-Control-Max-Age"] = "600"
|
||||||
|
return response
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user