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__/
|
||||
*.pyc
|
||||
.coverage
|
||||
.env
|
||||
hc.sqlite
|
||||
hc/local_settings.py
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
* Python 2 or Python 3
|
||||
* Django 1.11
|
||||
* Python 3.6+
|
||||
* Django 3
|
||||
* 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
|
||||
|
||||
These are instructions for setting up healthchecks Django app
|
||||
in development environment.
|
||||
To set up Healthchecks 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
|
||||
$ cd ~/webapps
|
||||
|
||||
* prepare virtual environment
|
||||
* Prepare virtual environment
|
||||
(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
|
||||
$ 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
|
||||
|
||||
* install requirements (Django, ...) into virtualenv:
|
||||
* Install requirements (Django, ...) into virtualenv:
|
||||
|
||||
$ 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
|
||||
$ cp hc/local_settings.py.example hc/local_settings.py
|
||||
|
||||
* create database tables and the superuser account:
|
||||
* Create database tables and a superuser account:
|
||||
|
||||
$ cd ~/webapps/healthchecks
|
||||
$ ./manage.py migrate
|
||||
$ ./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
|
||||
|
||||
The site should now be running at `http://localhost:8080`
|
||||
To log into Django administration site as a super user,
|
||||
visit `http://localhost:8080/admin`
|
||||
The site should now be running at `http://localhost:8000`.
|
||||
To access Django administration site, log in as a superuser, then
|
||||
visit `http://localhost:8000/admin/`
|
||||
|
||||
## Configuration
|
||||
|
||||
Site configuration is kept in `hc/settings.py`. Additional configuration
|
||||
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.
|
||||
Healthchecks reads configuration from environment variables.
|
||||
|
||||
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
|
||||
emails and notifications. Example:
|
||||
## Accessing Administration Panel
|
||||
|
||||
```python
|
||||
SITE_ROOT = "https://my-monitoring-project.com"
|
||||
```
|
||||
Healthchecks comes with Django's administration panel where you can manually
|
||||
view and modify user accounts, projects, checks, integrations etc. To access it,
|
||||
|
||||
`SITE_NAME` has the default value of "Mychecks" and is used throughout
|
||||
the templates. Replace it with your own name to personalize your installation.
|
||||
Example:
|
||||
* if you haven't already, create a superuser account: `./manage.py createsuperuser`
|
||||
* log into the site using superuser credentials
|
||||
* 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
|
||||
|
||||
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
|
||||
`hc/local_settings.py` like so:
|
||||
Healthchecks must be able to send email messages, so it can send out login
|
||||
links and alerts to users. Specify your SMTP credentials using the following
|
||||
environment variables:
|
||||
|
||||
```python
|
||||
EMAIL_HOST = "your-smtp-server-here.com"
|
||||
EMAIL_PORT = 587
|
||||
EMAIL_HOST_USER = "username"
|
||||
EMAIL_HOST_PASSWORD = "password"
|
||||
EMAIL_HOST_USER = "smtp-username"
|
||||
EMAIL_HOST_PASSWORD = "smtp-password"
|
||||
EMAIL_USE_TLS = True
|
||||
```
|
||||
|
||||
For more information, have a look at Django documentation,
|
||||
[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
|
||||
|
||||
healtchecks comes with a `sendalerts` management command, which continuously
|
||||
@ -183,7 +155,7 @@ manager like [supervisor](http://supervisord.org/) or systemd.
|
||||
|
||||
## 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
|
||||
to users, records of outgoing email messages and records of received pings.
|
||||
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
|
||||
```
|
||||
|
||||
* Remove checks older than 2 hours that are not assigned to users. Such
|
||||
checks are by-products of random visitors and robots loading the welcome
|
||||
page and never setting up an account:
|
||||
|
||||
```
|
||||
$ ./manage.py prunechecks
|
||||
```
|
||||
Note: 100 is the default value but you can configure a different
|
||||
limit per-user. To do that, go to the
|
||||
Administration Panel, look up user's **Profile** and modify its
|
||||
"Ping log limit" field.
|
||||
|
||||
* Remove old records of sent notifications. For each check, remove
|
||||
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
|
||||
```
|
||||
|
||||
* 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
|
||||
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
|
||||
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
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
development server on `localhost:8000` then the redirect URI would be
|
||||
`http://localhost:8000/integrations/add_discord/`
|
||||
* Look up your Discord app's Client ID and Client Secret. Add them
|
||||
to your `hc/local_settings.py` file as `DISCORD_CLIENT_ID` and
|
||||
`DISCORD_CLIENT_SECRET` fields.
|
||||
* Look up your Discord app's Client ID and Client Secret. Put them
|
||||
in `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET` environment
|
||||
variables.
|
||||
|
||||
|
||||
### 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
|
||||
|
||||
* Create a Telegram bot by talking to the
|
||||
[BotFather](https://core.telegram.org/bots#6-botfather). Set the bot's name,
|
||||
description, user picture, and add a "/start" command.
|
||||
* After creating the bot you will have the bot's name and token. Add them
|
||||
to your `hc/local_settings.py` file as `TELEGRAM_BOT_NAME` and
|
||||
`TELEGRAM_TOKEN` fields.
|
||||
* After creating the bot you will have the bot's name and token. Put them
|
||||
in `TELEGRAM_BOT_NAME` and `TELEGRAM_TOKEN` environment variables.
|
||||
* Run `settelegramwebhook` management command. This command tells Telegram
|
||||
where to forward channel messages by invoking Telegram's
|
||||
[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://"
|
||||
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.auth import login as auth_login
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
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.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from hc.accounts.models import Profile
|
||||
from hc.api.models import Channel, Check
|
||||
from hc.accounts.models import Credential, Profile, Project
|
||||
|
||||
|
||||
@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:
|
||||
@ -20,110 +43,200 @@ class Fieldset:
|
||||
|
||||
class ProfileFieldset(Fieldset):
|
||||
name = "User Profile"
|
||||
fields = ("email", "api_key", "current_team", "reports_allowed",
|
||||
"next_report_date", "nag_period", "next_nag_date",
|
||||
"token", "sort")
|
||||
fields = (
|
||||
"email",
|
||||
"reports",
|
||||
"tz",
|
||||
"theme",
|
||||
"next_report_date",
|
||||
"nag_period",
|
||||
"next_nag_date",
|
||||
"deletion_notice_date",
|
||||
"token",
|
||||
"sort",
|
||||
)
|
||||
|
||||
|
||||
class TeamFieldset(Fieldset):
|
||||
name = "Team"
|
||||
fields = ("team_name", "team_limit", "check_limit",
|
||||
"ping_log_limit", "sms_limit", "sms_sent", "last_sms_date",
|
||||
"bill_to")
|
||||
fields = (
|
||||
"team_limit",
|
||||
"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)
|
||||
class ProfileAdmin(admin.ModelAdmin):
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
'all': ('css/admin/profiles.css',)
|
||||
}
|
||||
css = {"all": ("css/admin/profiles.css",)}
|
||||
|
||||
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"]
|
||||
list_filter = ("team_limit", "reports_allowed",
|
||||
"check_limit", "next_report_date")
|
||||
list_per_page = 30
|
||||
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())
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super(ProfileAdmin, self).get_queryset(request)
|
||||
qs = qs.annotate(Count("member", distinct=True))
|
||||
qs = qs.annotate(Count("user__check", distinct=True))
|
||||
qs = qs.prefetch_related("user__project_set")
|
||||
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
|
||||
|
||||
@mark_safe
|
||||
def users(self, obj):
|
||||
if obj.member__count == 0:
|
||||
return obj.user.email
|
||||
else:
|
||||
return render_to_string("admin/profile_list_team.html", {
|
||||
"profile": obj
|
||||
})
|
||||
def email(self, obj):
|
||||
s = escape(obj.user.email)
|
||||
if obj.plan:
|
||||
s = "%s <span>%s</span>" % (s, obj.plan)
|
||||
|
||||
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
|
||||
def checks(self, obj):
|
||||
num_checks = obj.user__check__count
|
||||
pct = 100 * num_checks / max(obj.check_limit, 1)
|
||||
pct = min(100, int(pct))
|
||||
|
||||
return """
|
||||
<span class="bar"><span style="width: %dpx"></span></span>
|
||||
%d of %d
|
||||
""" % (pct, num_checks, obj.check_limit)
|
||||
s = "%d of %d" % (obj.num_checks, obj.check_limit)
|
||||
if obj.num_checks > 1:
|
||||
s = "<b>%s</b>" % s
|
||||
return s
|
||||
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
actions = ["send_report"]
|
||||
list_display = ('id', 'email', 'date_joined', 'engagement',
|
||||
'is_staff', 'checks')
|
||||
actions = ["send_report", "send_nag"]
|
||||
list_display = (
|
||||
"id",
|
||||
"email",
|
||||
"usage",
|
||||
"date_joined",
|
||||
"last_login",
|
||||
"is_staff",
|
||||
)
|
||||
|
||||
list_display_links = ("id", "email")
|
||||
list_filter = ("last_login", "date_joined", "is_staff", "is_active")
|
||||
|
||||
ordering = ["-id"]
|
||||
|
||||
def engagement(self, user):
|
||||
result = ""
|
||||
num_checks = Check.objects.filter(user=user).count()
|
||||
num_channels = Channel.objects.filter(user=user).count()
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(num_checks=Count("project__check", distinct=True))
|
||||
qs = qs.annotate(num_channels=Count("project__channel", distinct=True))
|
||||
|
||||
if num_checks == 0:
|
||||
result += "0 checks, "
|
||||
elif num_checks == 1:
|
||||
result += "1 check, "
|
||||
else:
|
||||
result += "<strong>%d checks</strong>, " % num_checks
|
||||
return qs
|
||||
|
||||
if num_channels == 0:
|
||||
result += "0 channels"
|
||||
elif num_channels == 1:
|
||||
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
|
||||
@mark_safe
|
||||
def usage(self, user):
|
||||
return _format_usage(user.num_checks, user.num_channels)
|
||||
|
||||
def send_report(self, request, qs):
|
||||
for user in qs:
|
||||
@ -131,6 +244,22 @@ class HcUserAdmin(UserAdmin):
|
||||
|
||||
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.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.conf import settings
|
||||
from hc.accounts.models import Profile
|
||||
from hc.accounts.views import _make_user
|
||||
|
||||
|
||||
class BasicBackend(object):
|
||||
|
||||
def get_user(self, user_id):
|
||||
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:
|
||||
return None
|
||||
|
||||
|
||||
# Authenticate against the token in user's profile.
|
||||
class ProfileBackend(BasicBackend):
|
||||
|
||||
def authenticate(self, request=None, username=None, token=None):
|
||||
try:
|
||||
profiles = Profile.objects.select_related("user")
|
||||
@ -28,7 +30,6 @@ class ProfileBackend(BasicBackend):
|
||||
|
||||
|
||||
class EmailBackend(BasicBackend):
|
||||
|
||||
def authenticate(self, request=None, username=None, password=None):
|
||||
try:
|
||||
user = User.objects.get(email=username)
|
||||
@ -37,3 +38,31 @@ class EmailBackend(BasicBackend):
|
||||
|
||||
if user.check_password(password):
|
||||
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 django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.auth import authenticate
|
||||
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):
|
||||
|
||||
def clean(self, value):
|
||||
value = super(LowercaseEmailField, self).clean(value)
|
||||
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()
|
||||
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):
|
||||
reports_allowed = forms.BooleanField(required=False)
|
||||
reports = forms.ChoiceField(choices=REPORT_CHOICES)
|
||||
nag_period = forms.IntegerField(min_value=0, max_value=86400)
|
||||
tz = forms.CharField()
|
||||
|
||||
def clean_nag_period(self):
|
||||
seconds = self.cleaned_data["nag_period"]
|
||||
@ -27,9 +108,18 @@ class ReportSettingsForm(forms.Form):
|
||||
|
||||
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):
|
||||
password = forms.CharField()
|
||||
password = forms.CharField(min_length=8)
|
||||
|
||||
|
||||
class ChangeEmailForm(forms.Form):
|
||||
@ -39,18 +129,51 @@ class ChangeEmailForm(forms.Form):
|
||||
def clean_email(self):
|
||||
v = self.cleaned_data["email"]
|
||||
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
|
||||
|
||||
|
||||
class InviteTeamMemberForm(forms.Form):
|
||||
email = LowercaseEmailField()
|
||||
email = LowercaseEmailField(max_length=254)
|
||||
role = forms.ChoiceField(choices=Member.Role.choices)
|
||||
|
||||
|
||||
class RemoveTeamMemberForm(forms.Form):
|
||||
email = LowercaseEmailField()
|
||||
|
||||
|
||||
class TeamNameForm(forms.Form):
|
||||
team_name = forms.CharField(max_length=200, required=True)
|
||||
class ProjectNameForm(forms.Form):
|
||||
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.core.management.base import BaseCommand
|
||||
from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
from django.db.models import Count, F
|
||||
from django.utils.timezone import now
|
||||
from hc.accounts.models import Profile
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """Prune old, inactive user accounts.
|
||||
|
||||
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.
|
||||
Use case: visitor types in their email at the website but
|
||||
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):
|
||||
cutoff = timezone.now() - timedelta(days=180)
|
||||
month_ago = now() - timedelta(days=30)
|
||||
|
||||
# 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.filter(date_joined__lt=cutoff, last_login=None, n_teams=0)
|
||||
n1, _ = q.delete()
|
||||
q = q.filter(date_joined__lt=month_ago, last_login=None, n_teams=0)
|
||||
|
||||
# Not logged in for 1 month, 0 checks, no team memberships
|
||||
q = User.objects
|
||||
q = q.annotate(n_checks=Count("check"))
|
||||
q = q.annotate(n_teams=Count("memberships"))
|
||||
q = q.filter(last_login__lt=cutoff, n_checks=0, n_teams=0)
|
||||
n2, _ = q.delete()
|
||||
n, summary = q.delete()
|
||||
count = summary.get("auth.User", 0)
|
||||
self.stdout.write("Pruned %d never-logged-in user accounts." % count)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -9,11 +13,51 @@ class TeamAccessMiddleware(object):
|
||||
if not request.user.is_authenticated:
|
||||
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.team = request.profile.team()
|
||||
|
||||
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):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Profile',
|
||||
name="Profile",
|
||||
fields=[
|
||||
('id', models.AutoField(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)),
|
||||
],
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
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):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
]
|
||||
dependencies = [("accounts", "0001_initial")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='ping_log_limit',
|
||||
model_name="profile",
|
||||
name="ping_log_limit",
|
||||
field=models.IntegerField(default=100),
|
||||
),
|
||||
)
|
||||
]
|
||||
|
@ -7,14 +7,12 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0002_profile_ping_log_limit'),
|
||||
]
|
||||
dependencies = [("accounts", "0002_profile_ping_log_limit")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='token',
|
||||
model_name="profile",
|
||||
name="token",
|
||||
field=models.CharField(blank=True, max_length=128),
|
||||
),
|
||||
)
|
||||
]
|
||||
|
@ -7,14 +7,12 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0003_profile_token'),
|
||||
]
|
||||
dependencies = [("accounts", "0003_profile_token")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='api_key',
|
||||
model_name="profile",
|
||||
name="api_key",
|
||||
field=models.CharField(blank=True, max_length=128),
|
||||
),
|
||||
)
|
||||
]
|
||||
|
@ -11,34 +11,46 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('accounts', '0004_profile_api_key'),
|
||||
("accounts", "0004_profile_api_key"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Member',
|
||||
name="Member",
|
||||
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(
|
||||
model_name='profile',
|
||||
name='team_access_allowed',
|
||||
model_name="profile",
|
||||
name="team_access_allowed",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='team_name',
|
||||
model_name="profile",
|
||||
name="team_name",
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='member',
|
||||
name='team',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Profile'),
|
||||
model_name="member",
|
||||
name="team",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="accounts.Profile"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='member',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
||||
model_name="member",
|
||||
name="user",
|
||||
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):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0005_auto_20160509_0801'),
|
||||
]
|
||||
dependencies = [("accounts", "0005_auto_20160509_0801")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='current_team',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.Profile'),
|
||||
model_name="profile",
|
||||
name="current_team",
|
||||
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):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0006_profile_current_team'),
|
||||
]
|
||||
dependencies = [("accounts", "0006_profile_current_team")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='check_limit',
|
||||
model_name="profile",
|
||||
name="check_limit",
|
||||
field=models.IntegerField(default=20),
|
||||
),
|
||||
)
|
||||
]
|
||||
|
@ -7,14 +7,10 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0007_profile_check_limit'),
|
||||
]
|
||||
dependencies = [("accounts", "0007_profile_check_limit")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='bill_to',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
model_name="profile", name="bill_to", field=models.TextField(blank=True)
|
||||
)
|
||||
]
|
||||
|
@ -7,24 +7,18 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0008_profile_bill_to'),
|
||||
]
|
||||
dependencies = [("accounts", "0008_profile_bill_to")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='last_sms_date',
|
||||
model_name="profile",
|
||||
name="last_sms_date",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='sms_limit',
|
||||
field=models.IntegerField(default=0),
|
||||
model_name="profile", name="sms_limit", field=models.IntegerField(default=0)
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='sms_sent',
|
||||
field=models.IntegerField(default=0),
|
||||
model_name="profile", name="sms_sent", field=models.IntegerField(default=0)
|
||||
),
|
||||
]
|
||||
|
@ -7,14 +7,12 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0009_auto_20170714_1734'),
|
||||
]
|
||||
dependencies = [("accounts", "0009_auto_20170714_1734")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='team_limit',
|
||||
model_name="profile",
|
||||
name="team_limit",
|
||||
field=models.IntegerField(default=2),
|
||||
),
|
||||
)
|
||||
]
|
||||
|
@ -7,14 +7,12 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0010_profile_team_limit'),
|
||||
]
|
||||
dependencies = [("accounts", "0010_profile_team_limit")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='sort',
|
||||
field=models.CharField(default='created', max_length=20),
|
||||
),
|
||||
model_name="profile",
|
||||
name="sort",
|
||||
field=models.CharField(default="created", max_length=20),
|
||||
)
|
||||
]
|
||||
|
@ -8,19 +8,24 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0011_profile_sort'),
|
||||
]
|
||||
dependencies = [("accounts", "0011_profile_sort")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='nag_period',
|
||||
field=models.DurationField(choices=[(datetime.timedelta(0), 'Disabled'), (datetime.timedelta(0, 3600), 'Hourly'), (datetime.timedelta(1), 'Daily')], default=datetime.timedelta(0)),
|
||||
model_name="profile",
|
||||
name="nag_period",
|
||||
field=models.DurationField(
|
||||
choices=[
|
||||
(datetime.timedelta(0), "Disabled"),
|
||||
(datetime.timedelta(0, 3600), "Hourly"),
|
||||
(datetime.timedelta(1), "Daily"),
|
||||
],
|
||||
default=datetime.timedelta(0),
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='next_nag_date',
|
||||
model_name="profile",
|
||||
name="next_nag_date",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
|
@ -7,13 +7,8 @@ from django.db import migrations
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0012_auto_20171014_1002'),
|
||||
]
|
||||
dependencies = [("accounts", "0012_auto_20171014_1002")]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='profile',
|
||||
name='team_access_allowed',
|
||||
),
|
||||
migrations.RemoveField(model_name="profile", name="team_access_allowed")
|
||||
]
|
||||
|
@ -9,14 +9,16 @@ import django.db.models.deletion
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0013_remove_profile_team_access_allowed'),
|
||||
]
|
||||
dependencies = [("accounts", "0013_remove_profile_team_access_allowed")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='member',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to=settings.AUTH_USER_MODEL),
|
||||
model_name="member",
|
||||
name="user",
|
||||
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
|
||||
import random
|
||||
from secrets import token_urlsafe
|
||||
from urllib.parse import quote, urlencode
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password, make_password
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.signing import TimestampSigner
|
||||
from django.db import models
|
||||
from django.db.models import Count, Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from fido2.ctap2 import AttestedCredentialData
|
||||
from hc.lib import emails
|
||||
|
||||
from hc.lib.date import month_boundaries
|
||||
import pytz
|
||||
|
||||
NO_NAG = timedelta()
|
||||
NAG_PERIODS = ((NO_NAG, "Disabled"),
|
||||
NAG_PERIODS = (
|
||||
(NO_NAG, "Disabled"),
|
||||
(timedelta(hours=1), "Hourly"),
|
||||
(timedelta(days=1), "Daily"))
|
||||
(timedelta(days=1), "Daily"),
|
||||
)
|
||||
|
||||
REPORT_CHOICES = (("off", "Off"), ("weekly", "Weekly"), ("monthly", "Monthly"))
|
||||
|
||||
|
||||
def month(dt):
|
||||
@ -33,6 +42,7 @@ class ProfileManager(models.Manager):
|
||||
# If not using payments, set high limits
|
||||
profile.check_limit = 500
|
||||
profile.sms_limit = 500
|
||||
profile.call_limit = 500
|
||||
profile.team_limit = 500
|
||||
|
||||
profile.save()
|
||||
@ -40,29 +50,37 @@ class ProfileManager(models.Manager):
|
||||
|
||||
|
||||
class Profile(models.Model):
|
||||
# Owner:
|
||||
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)
|
||||
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)
|
||||
next_nag_date = models.DateTimeField(null=True, blank=True)
|
||||
ping_log_limit = models.IntegerField(default=100)
|
||||
check_limit = models.IntegerField(default=20)
|
||||
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)
|
||||
sms_limit = models.IntegerField(default=0)
|
||||
sms_limit = models.IntegerField(default=5)
|
||||
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)
|
||||
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()
|
||||
|
||||
def __str__(self):
|
||||
return self.team_name or self.user.email
|
||||
return "Profile for %s" % self.user.email
|
||||
|
||||
def notifications_url(self):
|
||||
return settings.SITE_ROOT + reverse("hc-notifications")
|
||||
@ -73,15 +91,8 @@ class Profile(models.Model):
|
||||
path = reverse("hc-unsubscribe-reports", args=[signed_username])
|
||||
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):
|
||||
token = urlsafe_b64encode(os.urandom(24)).decode("utf-8")
|
||||
token = token_urlsafe(24)
|
||||
self.token = make_password(token, salt)
|
||||
self.save()
|
||||
return token
|
||||
@ -89,52 +100,86 @@ class Profile(models.Model):
|
||||
def check_token(self, token, salt):
|
||||
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")
|
||||
path = reverse("hc-check-token", args=[self.user.username, token])
|
||||
if redirect_url:
|
||||
path += "?next=%s" % redirect_url
|
||||
|
||||
ctx = {
|
||||
"button_text": "Log In",
|
||||
"button_text": "Sign In",
|
||||
"button_url": settings.SITE_ROOT + path,
|
||||
"inviting_profile": inviting_profile
|
||||
"inviting_project": inviting_project,
|
||||
}
|
||||
emails.login(self.user.email, ctx)
|
||||
|
||||
def send_set_password_link(self):
|
||||
token = self.prepare_token("set-password")
|
||||
path = reverse("hc-set-password", args=[token])
|
||||
def send_transfer_request(self, project):
|
||||
token = self.prepare_token("login")
|
||||
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 = {
|
||||
"button_text": "Set Password",
|
||||
"button_url": settings.SITE_ROOT + path
|
||||
"button_text": "Project Settings",
|
||||
"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):
|
||||
token = self.prepare_token("change-email")
|
||||
path = reverse("hc-change-email", args=[token])
|
||||
ctx = {
|
||||
"button_text": "Change Email",
|
||||
"button_url": settings.SITE_ROOT + path
|
||||
}
|
||||
emails.change_email(self.user.email, ctx)
|
||||
def send_sms_limit_notice(self, transport):
|
||||
ctx = {"transport": transport, "limit": self.sms_limit}
|
||||
if self.sms_limit != 500 and settings.USE_PAYMENTS:
|
||||
ctx["url"] = settings.SITE_ROOT + reverse("hc-pricing")
|
||||
|
||||
def set_api_key(self):
|
||||
self.api_key = urlsafe_b64encode(os.urandom(24))
|
||||
self.save()
|
||||
emails.sms_limit(self.user.email, ctx)
|
||||
|
||||
def checks_from_all_teams(self):
|
||||
""" Return a queryset of checks from all teams we have access for. """
|
||||
def send_call_limit_notice(self):
|
||||
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))
|
||||
team_ids.add(self.id)
|
||||
emails.call_limit(self.user.email, ctx)
|
||||
|
||||
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
|
||||
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):
|
||||
checks = self.checks_from_all_teams()
|
||||
checks = self.checks_from_all_projects()
|
||||
|
||||
# Is there at least one check that has received a ping?
|
||||
if not checks.filter(last_ping__isnull=False).exists():
|
||||
# Has there been a ping in last 6 months?
|
||||
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
|
||||
|
||||
# Is there at least one check that is down?
|
||||
@ -142,42 +187,42 @@ class Profile(models.Model):
|
||||
if nag and num_down == 0:
|
||||
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.
|
||||
checks = checks.select_related("user", "user__profile")
|
||||
checks = checks.order_by("user_id")
|
||||
checks = checks.select_related("project")
|
||||
checks = checks.order_by("project_id")
|
||||
# list() executes the query, to avoid DB access while
|
||||
# rendering the template
|
||||
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 = {
|
||||
"checks": checks,
|
||||
"sort": self.sort,
|
||||
"now": timezone.now(),
|
||||
"unsub_link": self.reports_unsub_url(),
|
||||
"unsub_link": unsub_url,
|
||||
"notifications_url": self.notifications_url(),
|
||||
"nag": nag,
|
||||
"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
|
||||
|
||||
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):
|
||||
# IF last_sms_date was never set, we have not sent any messages yet.
|
||||
if not self.last_sms_date:
|
||||
@ -201,19 +246,211 @@ class Profile(models.Model):
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def set_next_nag_date(self):
|
||||
""" Set next_nag_date for all members of this team. """
|
||||
def calls_sent_this_month(self):
|
||||
# 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)
|
||||
is_member = models.Q(user__memberships__team=self)
|
||||
q = Profile.objects.filter(is_owner | is_member)
|
||||
q = q.exclude(nag_period=NO_NAG)
|
||||
# Exclude profiles with next_nag_date already set
|
||||
q = q.filter(next_nag_date__isnull=True)
|
||||
# If last sent date is not from this month, we've made 0 calls this month.
|
||||
if month(timezone.now()) > month(self.last_call_date):
|
||||
return 0
|
||||
|
||||
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):
|
||||
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")
|
||||
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):
|
||||
|
||||
def setUp(self):
|
||||
super(AccountsAdminTestCase, self).setUp()
|
||||
super().setUp()
|
||||
|
||||
self.alice.is_staff = 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
|
||||
|
||||
|
||||
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):
|
||||
self.profile.token = make_password("foo", "change-email")
|
||||
self.profile.save()
|
||||
|
||||
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")
|
||||
|
||||
def test_it_changes_password(self):
|
||||
self.profile.token = make_password("foo", "change-email")
|
||||
self.profile.save()
|
||||
|
||||
def test_it_updates_email(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
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.assertEqual(self.alice.email, "alice2@example.org")
|
||||
self.assertFalse(self.alice.has_usable_password())
|
||||
|
||||
def test_it_requires_unique_email(self):
|
||||
self.profile.token = make_password("foo", "change-email")
|
||||
self.profile.save()
|
||||
# The user should have been logged out:
|
||||
self.assertNotIn("_auth_user_id", self.client.session)
|
||||
|
||||
def test_it_requires_unique_email(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
payload = {"email": "bob@example.org"}
|
||||
r = self.client.post("/accounts/change_email/foo/", payload)
|
||||
self.assertContains(r, "bob@example.org is not available")
|
||||
r = self.client.post("/accounts/change_email/", payload)
|
||||
self.assertContains(r, "bob@example.org is already registered")
|
||||
|
||||
self.alice.refresh_from_db()
|
||||
self.assertEqual(self.alice.email, "alice@example.org")
|
||||
|
@ -1,21 +1,24 @@
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from hc.accounts.models import Credential
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class CheckTokenTestCase(BaseTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(CheckTokenTestCase, self).setUp()
|
||||
super().setUp()
|
||||
self.profile.token = make_password("secret-token", "login")
|
||||
self.profile.save()
|
||||
|
||||
self.checks_url = "/projects/%s/checks/" % self.project.code
|
||||
|
||||
def test_it_shows_form(self):
|
||||
r = self.client.get("/accounts/check_token/alice/secret-token/")
|
||||
self.assertContains(r, "You are about to log in")
|
||||
|
||||
def test_it_redirects(self):
|
||||
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
|
||||
self.profile.refresh_from_db()
|
||||
@ -27,7 +30,8 @@ class CheckTokenTestCase(BaseTestCase):
|
||||
|
||||
# Login again, when already authenticated
|
||||
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):
|
||||
# Login with a bad token
|
||||
@ -35,3 +39,28 @@ class CheckTokenTestCase(BaseTestCase):
|
||||
r = self.client.post(url, follow=True)
|
||||
self.assertRedirects(r, "/accounts/login/")
|
||||
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 hc.api.models import Check
|
||||
from hc.payments.models import Subscription
|
||||
from hc.test import BaseTestCase
|
||||
from mock import patch
|
||||
|
||||
|
||||
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")
|
||||
def test_it_works(self, mock_cancel):
|
||||
Check.objects.create(user=self.alice, tags="foo a-B_1 baz@")
|
||||
Subscription.objects.create(user=self.alice, subscription_id="123")
|
||||
r = self.client.get("/accounts/close/")
|
||||
self.assertContains(r, "We have sent a confirmation code")
|
||||
|
||||
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")
|
||||
r = self.client.post("/accounts/close/")
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.set_sudo_flag()
|
||||
|
||||
payload = {"confirmation": "alice@example.org"}
|
||||
r = self.client.post("/accounts/close/", payload)
|
||||
self.assertRedirects(r, "/")
|
||||
|
||||
# Alice should be gone
|
||||
alices = User.objects.filter(username="alice")
|
||||
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
|
||||
self.assertFalse(Check.objects.exists())
|
||||
|
||||
# Subscription should have been canceled
|
||||
self.assertTrue(mock_cancel.called)
|
||||
self.assertTrue(mock_braintree.Subscription.cancel.called)
|
||||
|
||||
# Subscription should be gone
|
||||
self.assertFalse(Subscription.objects.exists())
|
||||
|
||||
def test_partner_removal_works(self):
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
r = self.client.post("/accounts/close/")
|
||||
self.assertEqual(r.status_code, 302)
|
||||
def test_it_requires_confirmation(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
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
|
||||
self.alice.refresh_from_db()
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.current_team, None)
|
||||
|
||||
# Bob should be gone
|
||||
bobs = User.objects.filter(username="bob")
|
||||
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.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):
|
||||
check = Check()
|
||||
check.save()
|
||||
|
||||
session = self.client.session
|
||||
session["welcome_code"] = str(check.code)
|
||||
session.save()
|
||||
|
||||
form = {"email": "alice@example.org"}
|
||||
form = {"identity": "alice@example.org"}
|
||||
|
||||
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.assertEqual(User.objects.count(), 1)
|
||||
|
||||
# And email sent
|
||||
# And email should have been sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
subject = "Log in to %s" % settings.SITE_NAME
|
||||
self.assertEqual(mail.outbox[0].subject, subject)
|
||||
|
||||
# And check should be associated with the new user
|
||||
check_again = Check.objects.get(code=check.code)
|
||||
assert check_again.user
|
||||
def test_it_sends_link_with_next(self):
|
||||
form = {"identity": "alice@example.org"}
|
||||
|
||||
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):
|
||||
self.client.session["bad_link"] = True
|
||||
self.client.get("/accounts/login/")
|
||||
assert "bad_link" not in self.client.session
|
||||
|
||||
def test_it_handles_missing_welcome_check(self):
|
||||
|
||||
# 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"}
|
||||
def test_it_ignores_case(self):
|
||||
form = {"identity": "ALICE@EXAMPLE.ORG"}
|
||||
|
||||
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.assertEqual(User.objects.count(), 1)
|
||||
self.profile.refresh_from_db()
|
||||
self.assertIn("login", self.profile.token)
|
||||
|
||||
# And email sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
subject = "Log in to %s" % settings.SITE_NAME
|
||||
self.assertEqual(mail.outbox[0].subject, subject)
|
||||
def test_it_handles_password(self):
|
||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||
|
||||
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)
|
||||
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)
|
||||
assert r.status_code == 200
|
||||
self.assertContains(r, "Incorrect email")
|
||||
self.assertRedirects(
|
||||
r, "/accounts/login/two_factor/", fetch_redirect_response=False
|
||||
)
|
||||
|
||||
def test_it_ignores_ces(self):
|
||||
alice = User(username="alice", email="alice@example.org")
|
||||
alice.save()
|
||||
# It should not log the user in yet
|
||||
self.assertNotIn("_auth_user_id", self.client.session)
|
||||
|
||||
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)
|
||||
assert r.status_code == 302
|
||||
self.assertRedirects(
|
||||
r, "/accounts/login/two_factor/totp/", fetch_redirect_response=False
|
||||
)
|
||||
|
||||
# There should be exactly one user:
|
||||
self.assertEqual(User.objects.count(), 1)
|
||||
# It should not log the user in yet
|
||||
self.assertNotIn("_auth_user_id", self.client.session)
|
||||
|
||||
profile = Profile.objects.for_user(alice)
|
||||
self.assertIn("login", profile.token)
|
||||
# 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)
|
||||
|
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 django.utils.timezone import now
|
||||
from hc.api.models import Check
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class NotificationsTestCase(BaseTestCase):
|
||||
url = "/accounts/profile/notifications/"
|
||||
|
||||
def test_it_saves_reports_allowed_true(self):
|
||||
self.profile.reports_allowed = False
|
||||
def _payload(self, **kwargs):
|
||||
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.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"reports_allowed": "on", "nag_period": "0"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
r = self.client.post(self.url, self._payload())
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertTrue(self.profile.reports_allowed)
|
||||
self.assertIsNotNone(self.profile.next_report_date)
|
||||
self.assertEqual(self.profile.reports, "monthly")
|
||||
self.assertEqual(self.profile.next_report_date.day, 1)
|
||||
|
||||
def test_it_saves_reports_allowed_false(self):
|
||||
self.profile.reports_allowed = True
|
||||
def test_it_saves_reports_weekly(self):
|
||||
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.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"nag_period": "0"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
r = self.client.post(self.url, self._payload(reports="off"))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertFalse(self.profile.reports_allowed)
|
||||
self.assertEqual(self.profile.reports, "off")
|
||||
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")
|
||||
|
||||
form = {"nag_period": "3600"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
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.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):
|
||||
self.profile.nag_period = td(seconds=3600)
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"nag_period": "1234"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
r = self.client.post(self.url, self._payload(nag_period="1234"))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
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.core import mail
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.timezone import now
|
||||
from django.test.utils import override_settings
|
||||
from hc.test import BaseTestCase
|
||||
from hc.accounts.models import Member
|
||||
from hc.api.models import Check
|
||||
from hc.accounts.models import Credential
|
||||
|
||||
|
||||
class ProfileTestCase(BaseTestCase):
|
||||
|
||||
def test_it_sends_set_password_link(self):
|
||||
def test_it_shows_profile_page(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"set_password": "1"}
|
||||
r = self.client.post("/accounts/profile/", form)
|
||||
assert r.status_code == 302
|
||||
r = self.client.get("/accounts/profile/")
|
||||
self.assertContains(r, "Email and Password")
|
||||
self.assertContains(r, "Change Password")
|
||||
self.assertContains(r, "Set Up Authenticator App")
|
||||
|
||||
# profile.token should be set now
|
||||
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):
|
||||
def test_leaving_works(self):
|
||||
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.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")
|
||||
|
||||
form = {"change_email": "1"}
|
||||
r = self.client.post("/accounts/profile/", form)
|
||||
assert r.status_code == 302
|
||||
r = self.client.get("/accounts/profile/")
|
||||
self.assertContains(r, "You do not have any projects. Create one!")
|
||||
|
||||
# profile.token should be set now
|
||||
self.profile.refresh_from_db()
|
||||
token = self.profile.token
|
||||
self.assertTrue(len(token) > 10)
|
||||
@override_settings(RP_ID=None)
|
||||
def test_it_hides_security_keys_bits_if_rp_id_not_set(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
# And an email should have been sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
expected_subject = "Change email address on %s" % settings.SITE_NAME
|
||||
self.assertEqual(mail.outbox[0].subject, expected_subject)
|
||||
r = self.client.get("/accounts/profile/")
|
||||
self.assertContains(r, "Two-factor Authentication")
|
||||
self.assertNotContains(r, "Security keys")
|
||||
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 unittest.mock import Mock
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from hc.accounts.management.commands.pruneusers import Command
|
||||
from hc.accounts.models import Project
|
||||
from hc.api.models import Check
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
@ -15,26 +17,20 @@ class PruneUsersTestCase(BaseTestCase):
|
||||
self.charlie.save()
|
||||
|
||||
# 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(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):
|
||||
self.bob.date_joined = self.year_ago
|
||||
self.bob.last_login = self.year_ago
|
||||
self.bob.save()
|
||||
|
||||
Command().handle()
|
||||
Command(stdout=Mock()).handle()
|
||||
|
||||
# Bob belongs to a team so should not get removed
|
||||
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):
|
||||
|
||||
def test_it_handles_missing_profile(self):
|
||||
user = User(username="ned", email="ned@example.org")
|
||||
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
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core import signing
|
||||
from django.utils.timezone import now
|
||||
@ -6,40 +8,55 @@ from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class UnsubscribeReportsTestCase(BaseTestCase):
|
||||
|
||||
def test_token_works(self):
|
||||
def test_it_unsubscribes(self):
|
||||
self.profile.next_report_date = now()
|
||||
self.profile.nag_period = td(hours=1)
|
||||
self.profile.next_nag_date = now()
|
||||
self.profile.save()
|
||||
|
||||
token = signing.Signer().sign("foo")
|
||||
url = "/accounts/unsubscribe_reports/alice/?token=%s" % token
|
||||
r = self.client.get(url)
|
||||
self.assertContains(r, "You have been unsubscribed")
|
||||
sig = signing.TimestampSigner(salt="reports").sign("alice")
|
||||
url = "/accounts/unsubscribe_reports/%s/" % sig
|
||||
|
||||
r = self.client.post(url)
|
||||
self.assertContains(r, "Unsubscribed")
|
||||
|
||||
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.assertEqual(self.profile.nag_period.total_seconds(), 0)
|
||||
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):
|
||||
url = "/accounts/unsubscribe_reports/invalid/"
|
||||
r = self.client.get(url)
|
||||
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
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^login/$', views.login, name="hc-login"),
|
||||
url(r'^logout/$', views.logout, name="hc-logout"),
|
||||
url(r'^login_link_sent/$',
|
||||
views.login_link_sent, name="hc-login-link-sent"),
|
||||
|
||||
url(r'^link_sent/$',
|
||||
views.link_sent, name="hc-link-sent"),
|
||||
|
||||
url(r'^check_token/([\w-]+)/([\w-]+)/$',
|
||||
views.check_token, name="hc-check-token"),
|
||||
|
||||
url(r'^profile/$', views.profile, name="hc-profile"),
|
||||
url(r'^profile/notifications/$', views.notifications, name="hc-notifications"),
|
||||
url(r'^profile/badges/$', views.badges, name="hc-badges"),
|
||||
url(r'^close/$', views.close, name="hc-close"),
|
||||
|
||||
url(r'^unsubscribe_reports/([\w\:-]+)/$',
|
||||
views.unsubscribe_reports, name="hc-unsubscribe-reports"),
|
||||
|
||||
url(r'^set_password/([\w-]+)/$',
|
||||
views.set_password, name="hc-set-password"),
|
||||
|
||||
url(r'^change_email/done/$',
|
||||
views.change_email_done, name="hc-change-email-done"),
|
||||
|
||||
url(r'^change_email/([\w-]+)/$',
|
||||
views.change_email, name="hc-change-email"),
|
||||
|
||||
url(r'^switch_team/([\w-]+)/$',
|
||||
views.switch_team, name="hc-switch-team"),
|
||||
|
||||
path("login/", views.login, name="hc-login"),
|
||||
path("login/two_factor/", views.login_webauthn, name="hc-login-webauthn"),
|
||||
path("login/two_factor/totp/", views.login_totp, name="hc-login-totp"),
|
||||
path("logout/", views.logout, name="hc-logout"),
|
||||
path("signup/", views.signup, name="hc-signup"),
|
||||
path("login_link_sent/", views.login_link_sent, name="hc-login-link-sent"),
|
||||
path(
|
||||
"check_token/<slug:username>/<slug:token>/",
|
||||
views.check_token,
|
||||
name="hc-check-token",
|
||||
),
|
||||
path("profile/", views.profile, name="hc-profile"),
|
||||
path("profile/appearance/", views.appearance, name="hc-appearance"),
|
||||
path("profile/notifications/", views.notifications, name="hc-notifications"),
|
||||
path("close/", views.close, name="hc-close"),
|
||||
path(
|
||||
"unsubscribe_reports/<str:signed_username>/",
|
||||
views.unsubscribe_reports,
|
||||
name="hc-unsubscribe-reports",
|
||||
),
|
||||
path("set_password/", views.set_password, name="hc-set-password"),
|
||||
path("change_email/done/", views.change_email_done, name="hc-change-email-done"),
|
||||
path("change_email/", views.change_email, name="hc-change-email"),
|
||||
path("two_factor/webauthn/", views.add_webauthn, name="hc-add-webauthn"),
|
||||
path("two_factor/totp/", views.add_totp, name="hc-add-totp"),
|
||||
path("two_factor/totp/remove/", views.remove_totp, name="hc-remove-totp"),
|
||||
path(
|
||||
"two_factor/<uuid:code>/remove/",
|
||||
views.remove_credential,
|
||||
name="hc-remove-credential",
|
||||
),
|
||||
]
|
||||
|
File diff suppressed because it is too large
Load Diff
199
hc/api/admin.py
199
hc/api/admin.py
@ -1,50 +1,61 @@
|
||||
from django.contrib import admin
|
||||
from django.core.paginator import Paginator
|
||||
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 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
|
||||
|
||||
|
||||
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)
|
||||
class ChecksAdmin(admin.ModelAdmin):
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
'all': ('css/admin/checks.css',)
|
||||
}
|
||||
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"]
|
||||
|
||||
def email(self, obj):
|
||||
return obj.user.email if obj.user else None
|
||||
def get_queryset(self, request):
|
||||
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):
|
||||
if not obj.tags:
|
||||
return obj.name
|
||||
url = reverse("hc-details", args=[obj.code])
|
||||
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):
|
||||
if obj.kind == "simple":
|
||||
return format_duration(obj.timeout)
|
||||
@ -53,27 +64,21 @@ class ChecksAdmin(admin.ModelAdmin):
|
||||
else:
|
||||
return "Unknown"
|
||||
|
||||
timeout_schedule.short_description = "Schedule"
|
||||
|
||||
@admin.action(description="Send Alert")
|
||||
def send_alert(self, request, 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())
|
||||
|
||||
send_alert.short_description = "Send Alert"
|
||||
|
||||
|
||||
class SchemeListFilter(admin.SimpleListFilter):
|
||||
title = "Scheme"
|
||||
parameter_name = 'scheme'
|
||||
parameter_name = "scheme"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('http', "HTTP"),
|
||||
('https', "HTTPS"),
|
||||
('email', "Email"),
|
||||
)
|
||||
return (("http", "HTTP"), ("https", "HTTPS"), ("email", "Email"))
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value():
|
||||
@ -83,7 +88,7 @@ class SchemeListFilter(admin.SimpleListFilter):
|
||||
|
||||
class MethodListFilter(admin.SimpleListFilter):
|
||||
title = "Method"
|
||||
parameter_name = 'method'
|
||||
parameter_name = "method"
|
||||
methods = ["HEAD", "GET", "POST", "PUT", "DELETE"]
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
@ -95,6 +100,20 @@ class MethodListFilter(admin.SimpleListFilter):
|
||||
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/
|
||||
class LargeTablePaginator(Paginator):
|
||||
""" Overrides the count method to get an estimate instead of actual count
|
||||
@ -104,8 +123,10 @@ class LargeTablePaginator(Paginator):
|
||||
def _get_estimate(self):
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute("SELECT reltuples FROM pg_class WHERE relname = %s",
|
||||
[self.object_list.query.model._meta.db_table])
|
||||
cursor.execute(
|
||||
"SELECT reltuples FROM pg_class WHERE relname = %s",
|
||||
[self.object_list.query.model._meta.db_table],
|
||||
)
|
||||
return int(cursor.fetchone()[0])
|
||||
except:
|
||||
return 0
|
||||
@ -130,70 +151,84 @@ class LargeTablePaginator(Paginator):
|
||||
# (i.e. is of type list).
|
||||
self._count = len(self.object_list)
|
||||
return self._count
|
||||
|
||||
count = property(_get_count)
|
||||
|
||||
|
||||
@admin.register(Ping)
|
||||
class PingsAdmin(admin.ModelAdmin):
|
||||
search_fields = ("owner__name", "owner__code", "owner__user__email")
|
||||
list_select_related = ("owner", "owner__user")
|
||||
list_display = ("id", "created", "check_name", "email", "scheme", "method",
|
||||
"ua")
|
||||
list_filter = ("created", SchemeListFilter, MethodListFilter)
|
||||
search_fields = ("owner__name", "owner__code")
|
||||
readonly_fields = ("owner",)
|
||||
list_select_related = ("owner",)
|
||||
list_display = ("id", "created", "owner", "scheme", "method", "ua")
|
||||
list_filter = ("created", SchemeListFilter, MethodListFilter, KindListFilter)
|
||||
|
||||
paginator = LargeTablePaginator
|
||||
|
||||
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
|
||||
show_full_result_count = False
|
||||
|
||||
|
||||
@admin.register(Channel)
|
||||
class ChannelsAdmin(admin.ModelAdmin):
|
||||
class Media:
|
||||
css = {
|
||||
'all': ('css/admin/channels.css',)
|
||||
}
|
||||
css = {"all": ("css/admin/channels.css",)}
|
||||
|
||||
search_fields = ["value", "user__email"]
|
||||
list_select_related = ("user", )
|
||||
list_display = ("id", "code", "email", "formatted_kind", "value",
|
||||
"num_notifications")
|
||||
list_filter = ("kind", )
|
||||
raw_id_fields = ("user", "checks", )
|
||||
|
||||
def email(self, obj):
|
||||
return obj.user.email if obj.user else None
|
||||
search_fields = ["value", "project__owner__email"]
|
||||
list_display = ("id", "transport", "name", "project_", "value", "ok")
|
||||
list_filter = ("kind",)
|
||||
raw_id_fields = ("project", "checks")
|
||||
|
||||
@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:
|
||||
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"
|
||||
|
||||
def num_notifications(self, obj):
|
||||
return Notification.objects.filter(channel=obj).count()
|
||||
|
||||
num_notifications.short_description = "# Notifications"
|
||||
@admin.display(boolean=True)
|
||||
def ok(self, obj):
|
||||
return False if obj.last_error else True
|
||||
|
||||
|
||||
@admin.register(Notification)
|
||||
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_display = ("id", "created", "check_status", "check_name",
|
||||
"channel_kind", "channel_value")
|
||||
list_display = (
|
||||
"id",
|
||||
"created",
|
||||
"check_status",
|
||||
"owner",
|
||||
"channel_kind",
|
||||
"channel_value",
|
||||
"error",
|
||||
)
|
||||
list_filter = ("created", "check_status", "channel__kind")
|
||||
|
||||
def check_name(self, obj):
|
||||
return obj.owner.name_then_code()
|
||||
raw_id_fields = ("channel",)
|
||||
|
||||
def channel_kind(self, obj):
|
||||
return obj.channel.kind
|
||||
|
||||
def channel_value(self, obj):
|
||||
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 re
|
||||
from functools import wraps
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import (HttpResponseBadRequest, HttpResponseForbidden,
|
||||
JsonResponse)
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from hc.accounts.models import Project
|
||||
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)
|
||||
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:
|
||||
api_key = request.META["HTTP_X_API_KEY"]
|
||||
else:
|
||||
api_key = request.json.get("api_key", "")
|
||||
api_key = str(request.json.get("api_key", ""))
|
||||
|
||||
if api_key == "":
|
||||
return make_error("wrong api_key")
|
||||
if len(api_key) != 32:
|
||||
return error("missing api key", 401)
|
||||
|
||||
try:
|
||||
request.user = User.objects.get(profile__api_key=api_key)
|
||||
except User.DoesNotExist:
|
||||
return HttpResponseForbidden()
|
||||
request.project = Project.objects.get(api_key=api_key)
|
||||
except Project.DoesNotExist:
|
||||
return error("wrong api key", 401)
|
||||
|
||||
request.readonly = False
|
||||
return f(request, *args, **kwds)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def validate_json(schema):
|
||||
""" Validate request.json contents against `schema`.
|
||||
def authorize_read(f):
|
||||
@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):
|
||||
@wraps(f)
|
||||
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:
|
||||
validate(request.json, schema)
|
||||
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 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
|
||||
|
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