diff --git a/.gitignore b/.gitignore index 2f54b33..a138844 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .vscode/ .pytest_cache/ .pyc +*.sqlite3 +*.db build/ dist/ django_cancan.egg-info/ diff --git a/Pipfile b/Pipfile index e86265f..db85269 100644 --- a/Pipfile +++ b/Pipfile @@ -12,10 +12,15 @@ django-environ = "*" setuptools = "*" wheel = "*" twine = "*" +black = "*" +django-extensions = "*" [requires] python_version = "3.7" [scripts] build = "python setup.py sdist bdist_wheel" -publish = "twine upload --skip-existing dist/*" \ No newline at end of file +publish = "twine upload --skip-existing dist/*" + +[pipenv] +allow_prereleases = true diff --git a/Pipfile.lock b/Pipfile.lock index ddde820..ece40ad 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "92347f6370ed224993619838c70f3ae1bf6aaa6d91c9f62c2668f6e4718552e8" + "sha256": "9b2ea523e8531bb7c01e7bf82085d82dc83610a176239d911f9de0385e17a0b9" }, "pipfile-spec": 6, "requires": { @@ -25,11 +25,11 @@ }, "django": { "hashes": [ - "sha256:1a63f5bb6ff4d7c42f62a519edc2adbb37f9b78068a5a862beff858b68e3dc8b", - "sha256:2d390268a13c655c97e0e2ede9d117007996db692c1bb93eabebd4fb7ea7012b" + "sha256:59c8125ca873ed3bdae9c12b146fbbd6ed8d0f743e4cf5f5817af50c51f1fc2f", + "sha256:b5fbb818e751f660fa2d576d9f40c34a4c615c8b48dd383f5216e609f383371f" ], "index": "pypi", - "version": "==3.1" + "version": "==3.1.1" }, "django-environ": { "hashes": [ @@ -55,6 +55,13 @@ } }, "develop": { + "appdirs": { + "hashes": [ + "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", + "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" + ], + "version": "==1.4.4" + }, "autopep8": { "hashes": [ "sha256:d21d3901cb0da6ebd1e83fc9b0dfbde8b46afc2ede4fe32fbda0c7c6118ca094" @@ -62,12 +69,19 @@ "index": "pypi", "version": "==1.5.4" }, + "black": { + "hashes": [ + "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea" + ], + "index": "pypi", + "version": "==20.8b1" + }, "bleach": { "hashes": [ - "sha256:2bce3d8fab545a6528c8fa5d9f9ae8ebc85a56da365c7f85180bfe96a35ef22f", - "sha256:3c4c520fdb9db59ef139915a5db79f8b51bc2a7257ea0389f30c846883430a4b" + "sha256:52b5919b81842b1854196eaae5ca29679a2f2e378905c346d3ca8227c2c66080", + "sha256:9f8ccbeb6183c6e6cddea37592dfb0167485c1e3b13b3363bc325aa8bda3adbd" ], - "version": "==3.1.5" + "version": "==3.2.1" }, "certifi": { "hashes": [ @@ -78,36 +92,44 @@ }, "cffi": { "hashes": [ - "sha256:0da50dcbccd7cb7e6c741ab7912b2eff48e85af217d72b57f80ebc616257125e", - "sha256:12a453e03124069b6896107ee133ae3ab04c624bb10683e1ed1c1663df17c13c", - "sha256:15419020b0e812b40d96ec9d369b2bc8109cc3295eac6e013d3261343580cc7e", - "sha256:15a5f59a4808f82d8ec7364cbace851df591c2d43bc76bcbe5c4543a7ddd1bf1", - "sha256:23e44937d7695c27c66a54d793dd4b45889a81b35c0751ba91040fe825ec59c4", - "sha256:29c4688ace466a365b85a51dcc5e3c853c1d283f293dfcc12f7a77e498f160d2", - "sha256:57214fa5430399dffd54f4be37b56fe22cedb2b98862550d43cc085fb698dc2c", - "sha256:577791f948d34d569acb2d1add5831731c59d5a0c50a6d9f629ae1cefd9ca4a0", - "sha256:6539314d84c4d36f28d73adc1b45e9f4ee2a89cdc7e5d2b0a6dbacba31906798", - "sha256:65867d63f0fd1b500fa343d7798fa64e9e681b594e0a07dc934c13e76ee28fb1", - "sha256:672b539db20fef6b03d6f7a14b5825d57c98e4026401fce838849f8de73fe4d4", - "sha256:6843db0343e12e3f52cc58430ad559d850a53684f5b352540ca3f1bc56df0731", - "sha256:7057613efefd36cacabbdbcef010e0a9c20a88fc07eb3e616019ea1692fa5df4", - "sha256:76ada88d62eb24de7051c5157a1a78fd853cca9b91c0713c2e973e4196271d0c", - "sha256:837398c2ec00228679513802e3744d1e8e3cb1204aa6ad408b6aff081e99a487", - "sha256:8662aabfeab00cea149a3d1c2999b0731e70c6b5bac596d95d13f643e76d3d4e", - "sha256:95e9094162fa712f18b4f60896e34b621df99147c2cee216cfa8f022294e8e9f", - "sha256:99cc66b33c418cd579c0f03b77b94263c305c389cb0c6972dac420f24b3bf123", - "sha256:9b219511d8b64d3fa14261963933be34028ea0e57455baf6781fe399c2c3206c", - "sha256:ae8f34d50af2c2154035984b8b5fc5d9ed63f32fe615646ab435b05b132ca91b", - "sha256:b9aa9d8818c2e917fa2c105ad538e222a5bce59777133840b93134022a7ce650", - "sha256:bf44a9a0141a082e89c90e8d785b212a872db793a0080c20f6ae6e2a0ebf82ad", - "sha256:c0b48b98d79cf795b0916c57bebbc6d16bb43b9fc9b8c9f57f4cf05881904c75", - "sha256:da9d3c506f43e220336433dffe643fbfa40096d408cb9b7f2477892f369d5f82", - "sha256:e4082d832e36e7f9b2278bc774886ca8207346b99f278e54c9de4834f17232f7", - "sha256:e4b9b7af398c32e408c00eb4e0d33ced2f9121fd9fb978e6c1b57edd014a7d15", - "sha256:e613514a82539fc48291d01933951a13ae93b6b444a88782480be32245ed4afa", - "sha256:f5033952def24172e60493b68717792e3aebb387a8d186c43c020d9363ee7281" - ], - "version": "==1.14.2" + "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", + "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", + "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", + "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", + "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", + "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", + "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", + "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", + "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", + "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", + "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", + "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", + "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", + "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", + "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", + "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", + "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", + "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", + "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", + "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", + "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", + "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", + "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", + "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", + "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", + "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", + "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", + "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", + "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", + "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", + "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", + "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", + "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", + "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", + "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", + "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" + ], + "version": "==1.14.3" }, "chardet": { "hashes": [ @@ -116,6 +138,13 @@ ], "version": "==3.0.4" }, + "click": { + "hashes": [ + "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", + "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" + ], + "version": "==7.1.2" + }, "colorama": { "hashes": [ "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", @@ -125,30 +154,38 @@ }, "cryptography": { "hashes": [ - "sha256:10c9775a3f31610cf6b694d1fe598f2183441de81cedcf1814451ae53d71b13a", - "sha256:180c9f855a8ea280e72a5d61cf05681b230c2dce804c48e9b2983f491ecc44ed", - "sha256:247df238bc05c7d2e934a761243bfdc67db03f339948b1e2e80c75d41fc7cc36", - "sha256:26409a473cc6278e4c90f782cd5968ebad04d3911ed1c402fc86908c17633e08", - "sha256:2a27615c965173c4c88f2961cf18115c08fedfb8bdc121347f26e8458dc6d237", - "sha256:2e26223ac636ca216e855748e7d435a1bf846809ed12ed898179587d0cf74618", - "sha256:321761d55fb7cb256b771ee4ed78e69486a7336be9143b90c52be59d7657f50f", - "sha256:4005b38cd86fc51c955db40b0f0e52ff65340874495af72efabb1bb8ca881695", - "sha256:4b9e96543d0784acebb70991ebc2dbd99aa287f6217546bb993df22dd361d41c", - "sha256:548b0818e88792318dc137d8b1ec82a0ab0af96c7f0603a00bb94f896fbf5e10", - "sha256:725875681afe50b41aee7fdd629cedbc4720bab350142b12c55c0a4d17c7416c", - "sha256:7a63e97355f3cd77c94bd98c59cb85fe0efd76ea7ef904c9b0316b5bbfde6ed1", - "sha256:94191501e4b4009642be21dde2a78bd3c2701a81ee57d3d3d02f1d99f8b64a9e", - "sha256:969ae512a250f869c1738ca63be843488ff5cc031987d302c1f59c7dbe1b225f", - "sha256:9f734423eb9c2ea85000aa2476e0d7a58e021bc34f0a373ac52a5454cd52f791", - "sha256:b45ab1c6ece7c471f01c56f5d19818ca797c34541f0b2351635a5c9fe09ac2e0", - "sha256:cc6096c86ec0de26e2263c228fb25ee01c3ff1346d3cfc219d67d49f303585af", - "sha256:dc3f437ca6353979aace181f1b790f0fc79e446235b14306241633ab7d61b8f8", - "sha256:e7563eb7bc5c7e75a213281715155248cceba88b11cb4b22957ad45b85903761", - "sha256:e7dad66a9e5684a40f270bd4aee1906878193ae50a4831922e454a2a457f1716", - "sha256:eb80a288e3cfc08f679f95da72d2ef90cb74f6d8a8ba69d2f215c5e110b2ca32", - "sha256:fa7fbcc40e2210aca26c7ac8a39467eae444d90a2c346cbcffd9133a166bcc67" - ], - "version": "==3.1" + "sha256:21b47c59fcb1c36f1113f3709d37935368e34815ea1d7073862e92f810dc7499", + "sha256:451cdf60be4dafb6a3b78802006a020e6cd709c22d240f94f7a0696240a17154", + "sha256:4549b137d8cbe3c2eadfa56c0c858b78acbeff956bd461e40000b2164d9167c6", + "sha256:48ee615a779ffa749d7d50c291761dc921d93d7cf203dca2db663b4f193f0e49", + "sha256:559d622aef2a2dff98a892eef321433ba5bc55b2485220a8ca289c1ecc2bd54f", + "sha256:5d52c72449bb02dd45a773a203196e6d4fae34e158769c896012401f33064396", + "sha256:65beb15e7f9c16e15934569d29fb4def74ea1469d8781f6b3507ab896d6d8719", + "sha256:680da076cad81cdf5ffcac50c477b6790be81768d30f9da9e01960c4b18a66db", + "sha256:762bc5a0df03c51ee3f09c621e1cee64e3a079a2b5020de82f1613873d79ee70", + "sha256:89aceb31cd5f9fc2449fe8cf3810797ca52b65f1489002d58fe190bfb265c536", + "sha256:983c0c3de4cb9fcba68fd3f45ed846eb86a2a8b8d8bc5bb18364c4d00b3c61fe", + "sha256:99d4984aabd4c7182050bca76176ce2dbc9fa9748afe583a7865c12954d714ba", + "sha256:9d9fc6a16357965d282dd4ab6531013935425d0dc4950df2e0cf2a1b1ac1017d", + "sha256:a7597ffc67987b37b12e09c029bd1dc43965f75d328076ae85721b84046e9ca7", + "sha256:ab010e461bb6b444eaf7f8c813bb716be2d78ab786103f9608ffd37a4bd7d490", + "sha256:b12e715c10a13ca1bd27fbceed9adc8c5ff640f8e1f7ea76416352de703523c8", + "sha256:b2bded09c578d19e08bd2c5bb8fed7f103e089752c9cf7ca7ca7de522326e921", + "sha256:b372026ebf32fe2523159f27d9f0e9f485092e43b00a5adacf732192a70ba118", + "sha256:cb179acdd4ae1e4a5a160d80b87841b3d0e0be84af46c7bb2cd7ece57a39c4ba", + "sha256:e97a3b627e3cb63c415a16245d6cef2139cca18bb1183d1b9375a1c14e83f3b3", + "sha256:f0e099fc4cc697450c3dd4031791559692dd941a95254cb9aeded66a7aa8b9bc", + "sha256:f99317a0fa2e49917689b8cf977510addcfaaab769b3f899b9c481bbd76730c2" + ], + "version": "==3.1.1" + }, + "django-extensions": { + "hashes": [ + "sha256:6809c89ca952f0e08d4e0766bc0101dfaf508d7649aced1180c091d737046ea7", + "sha256:dc663652ac9460fd06580a973576820430c6d428720e874ae46b041fa63e0efa" + ], + "index": "pypi", + "version": "==3.0.9" }, "docutils": { "hashes": [ @@ -166,11 +203,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83", - "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070" + "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da", + "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3" ], "markers": "python_version < '3.8'", - "version": "==1.7.0" + "version": "==2.0.0" }, "jeepney": { "hashes": [ @@ -187,6 +224,13 @@ ], "version": "==21.4.0" }, + "mypy-extensions": { + "hashes": [ + "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", + "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" + ], + "version": "==0.4.3" + }, "packaging": { "hashes": [ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", @@ -194,6 +238,13 @@ ], "version": "==20.4" }, + "pathspec": { + "hashes": [ + "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", + "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061" + ], + "version": "==0.8.0" + }, "pkginfo": { "hashes": [ "sha256:7424f2c8511c186cd5424bbf31045b77435b37a8d604990b79d4e70d741148bb", @@ -217,17 +268,17 @@ }, "pygments": { "hashes": [ - "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", - "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" + "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998", + "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7" ], - "version": "==2.6.1" + "version": "==2.7.1" }, "pyparsing": { "hashes": [ - "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", - "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" + "sha256:1060635ca5ac864c2b7bc7b05a448df4e32d7d8c65e33cbe1514810d339672a2", + "sha256:56a551039101858c9e189ac9e66e330a03fb7079e97ba6b50193643905f450ce" ], - "version": "==2.4.7" + "version": "==3.0.0a2" }, "readme-renderer": { "hashes": [ @@ -236,6 +287,32 @@ ], "version": "==26.0" }, + "regex": { + "hashes": [ + "sha256:088afc8c63e7bd187a3c70a94b9e50ab3f17e1d3f52a32750b5b77dbe99ef5ef", + "sha256:1fe0a41437bbd06063aa184c34804efa886bcc128222e9916310c92cd54c3b4c", + "sha256:41bb65f54bba392643557e617316d0d899ed5b4946dccee1cb6696152b29844b", + "sha256:4318d56bccfe7d43e5addb272406ade7a2274da4b70eb15922a071c58ab0108c", + "sha256:4707f3695b34335afdfb09be3802c87fa0bc27030471dbc082f815f23688bc63", + "sha256:5533a959a1748a5c042a6da71fe9267a908e21eded7a4f373efd23a2cbdb0ecc", + "sha256:5f18875ac23d9aa2f060838e8b79093e8bb2313dbaaa9f54c6d8e52a5df097be", + "sha256:60b0e9e6dc45683e569ec37c55ac20c582973841927a85f2d8a7d20ee80216ab", + "sha256:84e9407db1b2eb368b7ecc283121b5e592c9aaedbe8c78b1a2f1102eb2e21d19", + "sha256:8d69cef61fa50c8133382e61fd97439de1ae623fe943578e477e76a9d9471637", + "sha256:9a02d0ae31d35e1ec12a4ea4d4cca990800f66a917d0fb997b20fbc13f5321fc", + "sha256:9bc13e0d20b97ffb07821aa3e113f9998e84994fe4d159ffa3d3a9d1b805043b", + "sha256:a6f32aea4260dfe0e55dc9733ea162ea38f0ea86aa7d0f77b15beac5bf7b369d", + "sha256:ae91972f8ac958039920ef6e8769277c084971a142ce2b660691793ae44aae6b", + "sha256:c570f6fa14b9c4c8a4924aaad354652366577b4f98213cf76305067144f7b100", + "sha256:d23a18037313714fb3bb5a94434d3151ee4300bae631894b1ac08111abeaa4a3", + "sha256:eaf548d117b6737df379fdd53bdde4f08870e66d7ea653e230477f071f861121", + "sha256:ebbe29186a3d9b0c591e71b7393f1ae08c83cb2d8e517d2a822b8f7ec99dfd8b", + "sha256:eda4771e0ace7f67f58bc5b560e27fb20f32a148cbc993b0c3835970935c2707", + "sha256:f1b3afc574a3db3b25c89161059d857bd4909a1269b0b3cb3c904677c8c4a3f7", + "sha256:f2388013e68e750eaa16ccbea62d4130180c26abb1d8e5d584b9baf69672b30f" + ], + "version": "==2020.9.27" + }, "requests": { "hashes": [ "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", @@ -281,10 +358,10 @@ }, "tqdm": { "hashes": [ - "sha256:1a336d2b829be50e46b84668691e0a2719f26c97c62846298dd5ae2937e4d5cf", - "sha256:564d632ea2b9cb52979f7956e093e831c28d441c11751682f84c86fc46e4fd21" + "sha256:2dd75fdb764f673b8187643496fcfbeac38348015b665878e582b152f3391cdb", + "sha256:93b7a6a9129fce904f6df4cf3ae7ff431d779be681a95c3344c26f3e6c09abfa" ], - "version": "==4.48.2" + "version": "==4.50.0" }, "twine": { "hashes": [ @@ -294,6 +371,40 @@ "index": "pypi", "version": "==3.2.0" }, + "typed-ast": { + "hashes": [ + "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", + "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", + "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", + "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", + "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", + "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", + "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", + "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", + "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", + "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", + "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", + "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", + "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", + "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", + "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", + "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", + "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", + "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", + "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", + "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", + "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" + ], + "version": "==1.4.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", + "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", + "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" + ], + "version": "==3.7.4.3" + }, "urllib3": { "hashes": [ "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", @@ -318,10 +429,10 @@ }, "zipp": { "hashes": [ - "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", - "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" + "sha256:43f4fa8d8bb313e65d8323a3952ef8756bf40f9a5c3ea7334be23ee4ec8278b6", + "sha256:b52f22895f4cfce194bc8172f3819ee8de7540aa6d873535a8668b730b8b411f" ], - "version": "==3.1.0" + "version": "==3.2.0" } } } diff --git a/README.md b/README.md index 7883bce..4a6375d 100644 --- a/README.md +++ b/README.md @@ -43,32 +43,43 @@ INSTALLED_APPS = [ ] ``` -2. Create a function that define user abilites. For example, in `abilities.py`: +2. Create a function that define the access rules for a given user. For example, create `abilities.py` in `myapp` module: ```python -def declare_abilities(user, ability): +def define_access_rules(user, rules): + # Anybody can view published articles + rules.allow('view', Article, published=True) + if not user.is_authenticated: - # Allow anonymous users to view published articles - return ability.can('view', Article, published=True) + return + + # Allow logged in user to view his own articles, regardless of the `published` status + rules.allow('view', Article, author=user) - if user.has_perm('article.view_own_article'): - # Allow logged in user to change his articles - return ability.can('change', Article, author=user) + if user.has_perm('article.view_unpublished'): + # You can also check for custom model permissions (i.e. view_unpublished) + rules.allow('view', Article, published=False) + if user.is_superuser: - # Allow superuser change all articles - return ability.can('change', Article) + # Superuser gets unlimited access to all articles + rules.allow('add', Article) + rules.allow('view', Article) + rules.allow('change', Article) + rules.allow('delete', Article) ``` -3. Configure `cancan` by adding `CANCAN` section in `settings.py`: +3. In `settings.py` add `CANCAN` section, so that `cancan` library will know where to search for `define_access_rules` function from the previous step: ```python CANCAN = { - 'ABILITIES': 'myapp.abilities.declare_abilities' + 'ABILITIES': 'myapp.abilities.define_access_rules' } ``` -Next, add `cancan` middleware after `AuthenticationMiddleware`: +The `define_access_rules` function will be executed automatically per each request by the `cancan` middleware. The middleware will call the function to determine the abilities of a current user. + +Let's add `cancan` middleware, just after `AuthenticationMiddleware`: ```python MIDDLEWARE = [ @@ -79,10 +90,13 @@ MIDDLEWARE = [ ] ``` -Adding the middleware adds `request.ability` instance which you can use -to check for: model permissions, object permissions and model querysets. +By adding the middleware you will also get an access to `request.ability` instance which you can use +to: + - check model permissions, + - check object permissions, + - generate model querysets (i.e. in case of `ListView`s -4. Check abilities in views: +4. Check for abilities in views: ```python @@ -90,7 +104,7 @@ class ArticleListView(ListView): model = Article def get_queryset(): - # this is how you can retrieve all objects a user can access + # this is how you can retrieve all objects that current user can access qs = self.request.ability.queryset_for('view', Article) return qs @@ -104,6 +118,101 @@ class ArticleDetailView(PermissionRequiredMixin, DetailView): return self.request.ability.can('view', article) ``` +5. Check for abilities in templates + +You can also check for abilities in template files, i. e. to show/hide/disable buttons or links. + +First you need to add `cancan` processor to `context_processors` in `TEMPLATES` section of `settings.py`: + +```python +TEMPLATES = [ + { + ..., + "OPTIONS": { + "context_processors": [ + ..., + "cancan.context_processors.abilities", + ], + }, + }, +] +``` + +This will give you access to `ability` object in a template. You also need add `{% load cancan_tags %}` at the beginning +of the template file. + +Next you can check for object permissions: + +``` +{% if ability|can:"change"|subject:article %} + Edit article +{% endif %} +``` + +or model permissions: + +``` +{% load cancan_tags %} + +... + +{% if ability|can:"add"|"myapp.Article" %} + Create new article +{% endif %} +``` + +You can also use `can` template tag to create a reusable variable: + +``` +{% can "add" "core.Project" as can_add_project %} +... +{% if can_add_project %} + ... +{% endif %} +``` + +## Checking for abilities in Django Rest Framework + +Let's start by creating a pemission class: + +```python +from rest_framework import permissions + +def set_aliases_for_drf_actions(ability): + """ + map DRF actions to default Django permissions + """ + ability.access_rules.set_alias("list", "view") + ability.access_rules.set_alias("retrieve", "view") + ability.access_rules.set_alias("create", "add") + ability.access_rules.set_alias("update", "change") + ability.access_rules.set_alias("partial_update", "change") + ability.access_rules.set_alias("destroy", "delete") + + +class AbilityPermission(permissions.BasePermission): + def has_permission(self, request, view=None): + ability = request.ability + set_aliases_for_drf_actions(ability) + return ability.can(view.action, view.get_queryset().model) + + def has_object_permission(self, request, view, obj): + ability = request.ability + set_aliases_for_drf_actions(ability) + return ability.can(view.action, obj) +``` + +Next, secure the ViewSet with `AbilityPermission` and override `get_queryset` method to list objects based on the access rights. + +```python +class ArticleViewset(ModelViewSet): + permission_classes = [AbilityPermission] + + def get_queryset(self): + return self.request.ability.queryset_for(self.action, Article).distinct() +``` + + ## Sponsors diff --git a/cancan/ability.py b/cancan/ability.py index 916a6c7..08b012a 100644 --- a/cancan/ability.py +++ b/cancan/ability.py @@ -1,54 +1,23 @@ import inspect from django.apps import apps +from .access_rules import AccessRules, normalize_subject class Ability: - def __init__(self, user): - self.user = user - self.abilities = [] - self.aliases = {} - - def can(self, action, model, **kwargs): - if type(model) is str: - model = apps.get_model(model) - self.abilities.append({ - 'type': 'can', - 'action': action, - 'model': model, - 'conditions': kwargs, - }) - - def cannot(self, action, model, **kwargs): - if type(model) is str: - model = apps.get_model(model) - - self.abilities.append({ - 'type': 'cannot', - 'action': action, - 'model': model, - 'conditions': kwargs, - }) - - def set_alias(self, alias, action): - self.aliases[alias] = action - - def alias_to_action(self, alias): - return self.aliases.get(alias, alias) - - -class AbilityValidator: - def __init__(self, ability: Ability): - self.ability = ability + def __init__(self, access_rules: AccessRules): + self.access_rules = access_rules def validate_model(self, action, model): can_count = 0 cannot_count = 0 model_abilities = filter( - lambda c: c['model'] == model and c['action'] == action, self.ability.abilities) + lambda c: c["subject"] == model and c["action"] == action, + self.access_rules.rules, + ) for c in model_abilities: - if c['type'] == 'can': + if c["type"] == "can": can_count += 1 - if c['type'] == 'cannot': + if c["type"] == "cannot": cannot_count += 1 if cannot_count > 0: @@ -60,16 +29,19 @@ def validate_model(self, action, model): def validate_instance(self, action, instance): model = instance._meta.model model_abilities = filter( - lambda c: c['model'] == model and c['action'] == action, self.ability.abilities) + lambda c: c["subject"] == model and c["action"] == action, + self.access_rules.rules, + ) query_sets = [] for c in model_abilities: - if c['type'] == 'can': - qs = model.objects.all().filter(pk=instance.id, **c.get('conditions', {})) + if c["type"] == "can": + qs = model.objects.all().filter( + pk=instance.id, **c.get("conditions", {}) + ) - if c['type'] == 'cannot': - raise NotImplementedError( - 'cannot-type rules are not yet implemented') + if c["type"] == "cannot": + raise NotImplementedError("cannot-type rules are not yet implemented") query_sets.append(qs) @@ -82,27 +54,30 @@ def validate_instance(self, action, instance): return can_query_set.count() > 0 - def can(self, action, model_or_instance) -> bool: - action = self.ability.alias_to_action(action) - if inspect.isclass(model_or_instance): - return self.validate_model(action, model_or_instance) + def can(self, action, subject) -> bool: + subject = normalize_subject(subject) + action = self.access_rules.alias_to_action(action) + if inspect.isclass(subject): + return self.validate_model(action, subject) else: - return self.validate_instance(action, model_or_instance) + return self.validate_instance(action, subject) def queryset_for(self, action, model): - action = self.ability.alias_to_action(action) + model = normalize_subject(model) + action = self.access_rules.alias_to_action(action) model_abilities = filter( - lambda c: c['model'] == model and c['action'] == action, self.ability.abilities) + lambda c: c["subject"] == model and c["action"] == action, + self.access_rules.rules, + ) query_sets = [] for c in model_abilities: - if c['type'] == 'can' and 'conditions' in c: - qs = model.objects.all().filter(**c.get('conditions', {})) + if c["type"] == "can" and "conditions" in c: + qs = model.objects.all().filter(**c.get("conditions", {})) - if c['type'] == 'cannot': - raise NotImplementedError( - 'cannot-type rules are not yet implemented') + if c["type"] == "cannot": + raise NotImplementedError("cannot-type rules are not yet implemented") query_sets.append(qs) @@ -114,3 +89,7 @@ def queryset_for(self, action, model): can_query_set |= qs return can_query_set + + def __contains__(self, item): + action, subject = item + return self.can(action, subject) \ No newline at end of file diff --git a/cancan/access_rules.py b/cancan/access_rules.py new file mode 100644 index 0000000..e260195 --- /dev/null +++ b/cancan/access_rules.py @@ -0,0 +1,34 @@ +from django.apps import apps + + +def normalize_subject(subject): + if isinstance(subject, str): + try: + app_label, model_name = subject.split(".") + return apps.get_model(app_label, model_name) + except Exception as e: + pass + return subject + + +class AccessRules: + def __init__(self, user): + self.user = user + self.rules = [] + self.action_aliases = {} + + def allow(self, action, subject, **kwargs): + rule = { + "type": "can", + "action": action, + "subject": normalize_subject(subject), + "conditions": kwargs, + } + self.rules.append(rule) + return rule + + def alias_action(self, action, alias): + self.action_aliases[alias] = action + + def alias_to_action(self, alias): + return self.action_aliases.get(alias, alias) diff --git a/cancan/apps.py b/cancan/apps.py index 6704f95..b5003da 100644 --- a/cancan/apps.py +++ b/cancan/apps.py @@ -2,8 +2,4 @@ class CanCanConfig(AppConfig): - name = 'cancan' - - def ready(self): - aaa() - pass + name = "cancan" diff --git a/cancan/context_processors.py b/cancan/context_processors.py new file mode 100644 index 0000000..a974920 --- /dev/null +++ b/cancan/context_processors.py @@ -0,0 +1,4 @@ +def abilities(request): + return { + "ability": request.ability, + } diff --git a/cancan/middleware.py b/cancan/middleware.py index 077a94f..94c67a0 100644 --- a/cancan/middleware.py +++ b/cancan/middleware.py @@ -3,38 +3,37 @@ from django.utils.deprecation import MiddlewareMixin from django.utils.functional import SimpleLazyObject from django.utils.module_loading import import_string - -from .ability import Ability, AbilityValidator +from .access_rules import AccessRules +from .ability import Ability def get_validator(request, declare_abilities): - ability = Ability(request.user) - declare_abilities(request.user, ability) - return AbilityValidator(ability) + access_rules = AccessRules(request.user) + declare_abilities(request.user, access_rules) + return Ability(access_rules) class CanCanMiddleware(MiddlewareMixin): def process_request(self, request): - assert hasattr(request, 'user'), ( + assert hasattr(request, "user"), ( "Cancan authentication middleware requires authenticationMiddleware middleware " "to be installed. Edit your MIDDLEWARE setting to insert " "'django.contrib.auth.middleware.AuthenticationMiddleware' before." "'cancan.middleware.CanCanMiddleware'" ) - assert hasattr(settings, 'CANCAN'), ( - "CANCAN section not found in settings" - ) - assert 'ABILITIES' in settings.CANCAN, ( - "CANCAN['ABILITIES'] is missing. It must point to a function" - ) - fn_name = settings.CANCAN['ABILITIES'] + assert hasattr(settings, "CANCAN"), "CANCAN section not found in settings" + assert ( + "ABILITIES" in settings.CANCAN + ), "CANCAN['ABILITIES'] is missing. It must point to a function" + fn_name = settings.CANCAN["ABILITIES"] - declare_abilities = import_string(settings.CANCAN['ABILITIES']) + declare_abilities = import_string(settings.CANCAN["ABILITIES"]) - assert callable(declare_abilities), ( - f"{fn_name} must be callabe function fn(user: User, ability: Ability)" - ) + assert callable( + declare_abilities + ), f"{fn_name} must be callabe function fn(user: User, ability: Ability)" request.ability = SimpleLazyObject( - lambda: get_validator(request, declare_abilities)) + lambda: get_validator(request, declare_abilities) + ) diff --git a/example_project/__init__.py b/cancan/templatetags/__init__.py similarity index 100% rename from example_project/__init__.py rename to cancan/templatetags/__init__.py diff --git a/cancan/templatetags/cancan_tags.py b/cancan/templatetags/cancan_tags.py new file mode 100644 index 0000000..c0e7b02 --- /dev/null +++ b/cancan/templatetags/cancan_tags.py @@ -0,0 +1,53 @@ +from django import template +from django.apps import apps +from django.utils.safestring import SafeString +from cancan.ability import Ability + +register = template.Library() + + +class AbilityCheck: + """ + This function is used internally to check for ability within a template i. e. + {% if ability|can:"view"|subject:project %} + View + {% endif %} + """ + + def __init__(self, ability): + self.ability = ability + self.action = None + self.subject = None + + def __repr__(self): + return f"{self.ability}(action={self.action},subject={self.subject})" + + def __bool__(self): + return self.ability.can(self.action, self.subject) + + +@register.filter +def can(ability, action): + if not isinstance(ability, AbilityCheck): + assert isinstance( + ability, Ability + ), f"can filter must be applied to Ability instance (you provided {type(ability)})" + ability = AbilityCheck(ability) + ability.action = action + return ability + + +@register.filter +def subject(ability, subject): + if not isinstance(ability, AbilityCheck): + assert isinstance( + ability, Ability + ), f"subject filter must be applied to Ability instance (you provided {type(ability)})" + ability = AbilityCheck(ability) + ability.subject = subject + return ability + + +@register.simple_tag(takes_context=True) +def can(context, action, subject): + return context["request"].ability.can(action, subject) diff --git a/cancan/testapp/migrations/0001_initial.py b/cancan/testapp/migrations/0001_initial.py index 9778ebf..26b8426 100644 --- a/cancan/testapp/migrations/0001_initial.py +++ b/cancan/testapp/migrations/0001_initial.py @@ -15,12 +15,28 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Article', + name="Article", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('is_published', models.BooleanField(default=False)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='articles', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("is_published", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="articles", + to=settings.AUTH_USER_MODEL, + ), + ), ], ), ] diff --git a/cancan/testapp/models.py b/cancan/testapp/models.py index c59c687..644acab 100644 --- a/cancan/testapp/models.py +++ b/cancan/testapp/models.py @@ -6,7 +6,8 @@ class Article(models.Model): name = models.CharField(max_length=255) is_published = models.BooleanField(default=False) created_by = models.ForeignKey( - User, on_delete=models.CASCADE, related_name='articles', null=True) + User, on_delete=models.CASCADE, related_name="articles", null=True + ) def get_absolute_url(self): return reverse("article_detail", kwargs={"pk": self.pk}) diff --git a/cancan/testapp/tests/test_core.py b/cancan/testapp/tests/test_core.py index dc52d5f..d4e6d19 100644 --- a/cancan/testapp/tests/test_core.py +++ b/cancan/testapp/tests/test_core.py @@ -1,6 +1,7 @@ from django.test import TestCase from cancan.testapp.models import Article, User -from cancan.ability import Ability, AbilityValidator +from cancan.ability import Ability +from cancan.access_rules import AccessRules class NoAbilitiesTestCase(TestCase): @@ -8,20 +9,20 @@ def setUp(self): self.user = User.objects.create(username="user1") def test_no_abilities_can_model(self): - ability = Ability(user=self.user) - validator = AbilityValidator(ability) - self.assertFalse(validator.can('view', Article)) + access_rules = AccessRules(user=self.user) + ability = Ability(access_rules) + self.assertFalse(ability.can("view", Article)) def test_no_abilities_can_object(self): - ability = Ability(user=self.user) - validator = AbilityValidator(ability) + access_rules = AccessRules(user=self.user) + ability = Ability(access_rules) article = Article.objects.create(name="foobar") - self.assertFalse(validator.can('view', article)) + self.assertFalse(ability.can("view", article)) def test_no_abilities_queryset_for(self): - ability = Ability(user=self.user) - validator = AbilityValidator(ability) - self.assertEqual(validator.queryset_for('view', Article).count(), 0) + access_rules = AccessRules(user=self.user) + ability = Ability(access_rules) + self.assertEqual(ability.queryset_for("view", Article).count(), 0) class ModelAbilitiesTestCase(TestCase): @@ -29,84 +30,94 @@ def setUp(self): self.user = User.objects.create(username="user1") def test_no_abilities_when_initialized(self): - ability = Ability(user=self.user) - validator = AbilityValidator(ability) - self.assertFalse(validator.can('view', Article)) + access_rules = AccessRules(user=self.user) + ability = Ability(access_rules) + self.assertFalse(ability.can("view", Article)) def test_happy_path(self): - ability = Ability(user=self.user) - ability.can('view', Article) - validator = AbilityValidator(ability) - self.assertTrue(validator.can('view', Article)) + access_rules = AccessRules(user=self.user) + access_rules.allow("view", Article) + ability = Ability(access_rules) + self.assertTrue(ability.can("view", Article)) def test_unknown_name(self): - ability = Ability(user=self.user) - ability.can('view', Article) - validator = AbilityValidator(ability) - self.assertFalse(validator.can('read', Article)) + access_rules = AccessRules(user=self.user) + access_rules.allow("view", Article) + ability = Ability(access_rules) + self.assertFalse(ability.can("read", Article)) class ObjectAbilitiesTestCase(TestCase): def setUp(self): - self.user = User.objects.create(username='user1') - ability = Ability(user=self.user) - ability.can('view', Article, is_published=True) - self.validator = AbilityValidator(ability) + self.user = User.objects.create(username="user1") + access_rules = AccessRules(user=self.user) + access_rules.allow("view", Article, is_published=True) + self.ability = Ability(access_rules) def test_can_view_published(self): - article = Article.objects.create(name='test', is_published=True) - self.assertTrue(self.validator.can( - 'view', article)) + article = Article.objects.create(name="test", is_published=True) + self.assertTrue(self.ability.can("view", article)) def test_cannot_view_unpublished(self): - article = Article.objects.create(name='test', is_published=False) - self.assertFalse(self.validator.can( - 'view', article)) + article = Article.objects.create(name="test", is_published=False) + self.assertFalse(self.ability.can("view", article)) def test_get_queryset1(self): - article1 = Article.objects.create(name='test', is_published=True) - qs = self.validator.queryset_for('view', Article) + article1 = Article.objects.create(name="test", is_published=True) + qs = self.ability.queryset_for("view", Article) self.assertEqual(qs.count(), 1) def test_get_queryset2(self): - article1 = Article.objects.create(name='test', is_published=False) - qs = self.validator.queryset_for('view', Article) + article1 = Article.objects.create(name="test", is_published=False) + qs = self.ability.queryset_for("view", Article) self.assertEqual(qs.count(), 0) class ObjectAbilitiesMultipleCanTestCase(TestCase): def setUp(self): - self.user = User.objects.create(username='user1') - ability = Ability(user=self.user) - ability.can('view', Article, is_published=True) - ability.can('view', Article, created_by=self.user) - self.validator = AbilityValidator(ability) + self.user = User.objects.create(username="user1") + access_rules = AccessRules(user=self.user) + access_rules.allow("view", Article, is_published=True) + access_rules.allow("view", Article, created_by=self.user) + self.ability = Ability(access_rules) def test_can_view_published(self): - article1 = Article.objects.create(name='test', is_published=True) - article2 = Article.objects.create(name='test', created_by=self.user) - self.assertTrue(self.validator.can('view', article1)) - self.assertTrue(self.validator.can('view', article2)) + article1 = Article.objects.create(name="test", is_published=True) + article2 = Article.objects.create(name="test", created_by=self.user) + self.assertTrue(self.ability.can("view", article1)) + self.assertTrue(self.ability.can("view", article2)) def test_queryset_contails_all_allowed(self): - article1 = Article.objects.create(name='test', is_published=True) - article2 = Article.objects.create(name='test', created_by=self.user) - article3 = Article.objects.create(name='test', is_published=False) - qs = self.validator.queryset_for('view', Article) + article1 = Article.objects.create(name="test", is_published=True) + article2 = Article.objects.create(name="test", created_by=self.user) + article3 = Article.objects.create(name="test", is_published=False) + qs = self.ability.queryset_for("view", Article) self.assertEqual(qs.count(), 2) self.assertFalse(article3 in qs.all()) class AliasesTestCase(TestCase): def setUp(self): - self.user = User.objects.create(username='user1') - article1 = Article.objects.create(name='test') - ability = Ability(user=self.user) - ability.can('view', Article) - ability.set_alias('list', 'view') - self.validator = AbilityValidator(ability) + self.user = User.objects.create(username="user1") + article1 = Article.objects.create(name="test") + access_rules = AccessRules(user=self.user) + access_rules.allow("view", Article) + access_rules.alias_action("view", "list") + self.ability = Ability(access_rules) def test_can_view_published(self): - self.assertTrue(self.validator.can('list', Article)) - self.assertEqual(self.validator.queryset_for( - 'list', Article).count(), 1) + self.assertTrue(self.ability.can("list", Article)) + self.assertEqual(self.ability.queryset_for("list", Article).count(), 1) + + +class MiscTestCase(TestCase): + def setUp(self): + self.user = User.objects.create(username="user1") + article1 = Article.objects.create(name="test") + access_rules = AccessRules(user=self.user) + access_rules.allow("view", Article) + access_rules.alias_action("view", "list") + self.ability = Ability(access_rules) + + def test_in_operator(self): + self.assertTrue(("view", Article) in self.ability) diff --git a/cancan/testapp/tests/test_middleware.py b/cancan/testapp/tests/test_middleware.py index 85c8294..c82f3b5 100644 --- a/cancan/testapp/tests/test_middleware.py +++ b/cancan/testapp/tests/test_middleware.py @@ -1,13 +1,15 @@ from django.test import TestCase, Client, override_settings from cancan.testapp.models import Article, User -from cancan.ability import Ability, AbilityValidator +from cancan.ability import Ability, AccessRules -def get_abilities(user, ability): - ability.can('view', Article, is_published=True) +def get_abilities(user, rules): + rules.allow("view", Article, is_published=True) -@override_settings(CANCAN={'ABILITIES': 'cancan.testapp.tests.test_middleware.get_abilities'}) +@override_settings( + CANCAN={"ABILITIES": "cancan.testapp.tests.test_middleware.get_abilities"} +) class MiddlewareTestCase(TestCase): def setUp(self): self.article1 = Article.objects.create(is_published=True) @@ -15,8 +17,8 @@ def setUp(self): def test_list_view(self): c = Client() - response = c.get('/articles/') - ids = response.content.decode().split(' ') + response = c.get("/articles/") + ids = response.content.decode().split(" ") self.assertEqual(response.status_code, 200) self.assertIn(str(self.article1.id), ids) self.assertNotIn(str(self.article2.id), ids) diff --git a/cancan/testapp/tests/urls.py b/cancan/testapp/tests/urls.py index c68886e..8eb1ef7 100644 --- a/cancan/testapp/tests/urls.py +++ b/cancan/testapp/tests/urls.py @@ -13,7 +13,7 @@ urlpatterns = [ - path('admin/', admin.site.urls), - path('accounts/login/', LoginView.as_view(template_name='blank.html')), - path('articles/', ArticleListView.as_view()) + path("admin/", admin.site.urls), + path("accounts/login/", LoginView.as_view(template_name="blank.html")), + path("articles/", ArticleListView.as_view()), ] diff --git a/cancan/testapp/testsettings.py b/cancan/testapp/testsettings.py index 1acc805..4000b05 100644 --- a/cancan/testapp/testsettings.py +++ b/cancan/testapp/testsettings.py @@ -10,63 +10,59 @@ ANONYMOUS_USER_NAME = "AnonymousUser" INSTALLED_APPS = ( - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.admin', - 'django.contrib.messages', - 'cancan', - 'cancan.testapp', + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.admin", + "django.contrib.messages", + "cancan", + "cancan.testapp", ) -AUTHENTICATION_BACKENDS = ( - 'django.contrib.auth.backends.ModelBackend', -) +AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",) MIDDLEWARE = ( - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'cancan.middleware.CanCanMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "cancan.middleware.CanCanMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", ) -CANCAN = {'ABILITIES': 'cancan.testapp.abilities.get_abilities'} +CANCAN = {"ABILITIES": "cancan.testapp.abilities.get_abilities"} # TEST_RUNNER = 'django.test.runner.DiscoverRunner' -ROOT_URLCONF = 'cancan.testapp.tests.urls' +ROOT_URLCONF = "cancan.testapp.tests.urls" SITE_ID = 1 -SECRET_KEY = ''.join([random.choice(string.ascii_letters) for x in range(40)]) +SECRET_KEY = "".join([random.choice(string.ascii_letters) for x in range(40)]) # Database specific # DATABASES = {'default': env.db(default="sqlite:///")} DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': './test.db', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "./test.db", } } TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': ( - os.path.join(os.path.dirname(__file__), 'tests', 'templates'), - ), - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.contrib.auth.context_processors.auth', - 'django.template.context_processors.debug', - 'django.template.context_processors.i18n', - 'django.template.context_processors.media', - 'django.template.context_processors.static', - 'django.template.context_processors.tz', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": (os.path.join(os.path.dirname(__file__), "tests", "templates"),), + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", ], }, }, diff --git a/cancan/testapp/views.py b/cancan/testapp/views.py index db75533..90a01bf 100644 --- a/cancan/testapp/views.py +++ b/cancan/testapp/views.py @@ -8,7 +8,7 @@ class ArticleListView(ListView): def get_queryset(self): # this is how you can retrieve all objects a user can access - qs = self.request.ability.queryset_for('view', Article) + qs = self.request.ability.queryset_for("view", Article) return qs @@ -18,4 +18,4 @@ class ArticleDetailView(PermissionRequiredMixin, DetailView): def has_permission(self): article = self.get_object() # this is how you can check if user can access an object - return self.request.ability.can('view', article) + return self.request.ability.can("view", article) diff --git a/example_project/Pipfile b/example_project/Pipfile index 02d53d1..f8c5973 100644 --- a/example_project/Pipfile +++ b/example_project/Pipfile @@ -7,6 +7,8 @@ verify_ssl = true [packages] django = "*" +django-bulma = "*" +pytest-django = "*" [requires] -python_version = "3.8" +python_version = "3.7" diff --git a/example_project/Pipfile.lock b/example_project/Pipfile.lock index a693b8f..511f084 100644 --- a/example_project/Pipfile.lock +++ b/example_project/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "99c4b9ec1b8891ff787677276760beb6d6d4919c55660da1c713682156a6086c" + "sha256": "9b588aa7cd657029acff3bb52771cbdeb02b970459efd2faef0733921404c32a" }, "pipfile-spec": 6, "requires": { - "python_version": "3.8" + "python_version": "3.7" }, "sources": [ { @@ -23,13 +23,93 @@ ], "version": "==3.2.10" }, + "attrs": { + "hashes": [ + "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", + "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" + ], + "version": "==20.2.0" + }, "django": { "hashes": [ - "sha256:1a63f5bb6ff4d7c42f62a519edc2adbb37f9b78068a5a862beff858b68e3dc8b", - "sha256:2d390268a13c655c97e0e2ede9d117007996db692c1bb93eabebd4fb7ea7012b" + "sha256:59c8125ca873ed3bdae9c12b146fbbd6ed8d0f743e4cf5f5817af50c51f1fc2f", + "sha256:b5fbb818e751f660fa2d576d9f40c34a4c615c8b48dd383f5216e609f383371f" + ], + "index": "pypi", + "version": "==3.1.1" + }, + "django-bulma": { + "hashes": [ + "sha256:369270933a3150ba7e5f85a160405a1713b405950b6082d72e003548d3403f1e", + "sha256:438ce96cb95228bb3fa30e959255f27bf9443ff5c441843b1e150103cc3205ec" + ], + "index": "pypi", + "version": "==0.8.1" + }, + "importlib-metadata": { + "hashes": [ + "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da", + "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3" + ], + "markers": "python_version < '3.8'", + "version": "==2.0.0" + }, + "iniconfig": { + "hashes": [ + "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437", + "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69" + ], + "version": "==1.0.1" + }, + "more-itertools": { + "hashes": [ + "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20", + "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c" + ], + "version": "==8.5.0" + }, + "packaging": { + "hashes": [ + "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", + "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" + ], + "version": "==20.4" + }, + "pluggy": { + "hashes": [ + "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", + "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" + ], + "version": "==0.13.1" + }, + "py": { + "hashes": [ + "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", + "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" + ], + "version": "==1.9.0" + }, + "pyparsing": { + "hashes": [ + "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", + "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" + ], + "version": "==2.4.7" + }, + "pytest": { + "hashes": [ + "sha256:0e37f61339c4578776e090c3b8f6b16ce4db333889d65d0efb305243ec544b40", + "sha256:c8f57c2a30983f469bf03e68cdfa74dc474ce56b8f280ddcb080dfd91df01043" + ], + "version": "==6.0.2" + }, + "pytest-django": { + "hashes": [ + "sha256:4de6dbd077ed8606616958f77655fed0d5e3ee45159475671c7fa67596c6dba6", + "sha256:c33e3d3da14d8409b125d825d4e74da17bb252191bf6fc3da6856e27a8b73ea4" ], "index": "pypi", - "version": "==3.1" + "version": "==3.10.0" }, "pytz": { "hashes": [ @@ -38,12 +118,33 @@ ], "version": "==2020.1" }, + "six": { + "hashes": [ + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + ], + "version": "==1.15.0" + }, "sqlparse": { "hashes": [ "sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e", "sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548" ], "version": "==0.3.1" + }, + "toml": { + "hashes": [ + "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", + "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" + ], + "version": "==0.10.1" + }, + "zipp": { + "hashes": [ + "sha256:43f4fa8d8bb313e65d8323a3952ef8756bf40f9a5c3ea7334be23ee4ec8278b6", + "sha256:b52f22895f4cfce194bc8172f3819ee8de7540aa6d873535a8668b730b8b411f" + ], + "version": "==3.2.0" } }, "develop": {} diff --git a/example_project/README.md b/example_project/README.md index 08843a0..6092215 100644 --- a/example_project/README.md +++ b/example_project/README.md @@ -1 +1,33 @@ -# This is readme for example_project +# Example project + +In this example project we are going to build a simple project management software. +Logged in users will be able to create projects. When a `Project` is created by a `User`, this user becomes a project's owner. +A project owner can add other users to a project and specify their role in a project (member or reporter). +Project members can create and resolve issues, reporters can only create issues. + +Since we are build + +## Access permissions + +Superuser - can do everything + +Project owner - can create new projects, manage project members, CRUD all projects and issues + +- can create new project +- view own projects +- can manage own project including: + - changing project details (name, description) + - managing (CRUD) project members + - managing (CRUD) project issues, including assigning members to issues + +Project member can: + +- view projects he belongs to, +- create issues in a projects he belongs to, +- edit own issues, including assigning issues to other project members, +- change status of issues he is assigned to, + +Project reporter: + +- can view projects he is assigned to, +- can create issues in a projects he is assigned to, diff --git a/example_project/abilities.py b/example_project/abilities.py index 041309d..5f8c706 100644 --- a/example_project/abilities.py +++ b/example_project/abilities.py @@ -1,17 +1,18 @@ -from sample.models import TodoItem +from core.models import Project, Membership, Issue -def declare_abilities(user, ability): - if not user.is_authenticated: +def declare_abilities(user, rules): + if not user.is_authenticated or not user.is_active: + # anonymous/inactive users can do nothing return False - # TODO: - # multiple can will be OR'ed - # after can, you can put cannot which will be overriden by following can + # logged in can view own content + rules.allow("add", Project) + rules.allow("view", Project, created_by=user) + rules.allow("change", Project, created_by=user) + rules.allow("delete", Project, created_by=user) - # Logged in user can view his own todos - ability.can('view', TodoItem, created_by=user.id) - - if user.has_perm('sample.view_todo'): - # OR condition - allow to view all todo items - ability.can('view', TodoItem) + if user.is_superuser: + rules.allow("view", Project) + rules.allow("change", Project) + rules.allow("delete", Project) diff --git a/example_project/asgi.py b/example_project/asgi.py index 132b4bb..714e8d0 100644 --- a/example_project/asgi.py +++ b/example_project/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") application = get_asgi_application() diff --git a/example_project/conftest.py b/example_project/conftest.py new file mode 100644 index 0000000..0e0e72c --- /dev/null +++ b/example_project/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture(autouse=True) +def enable_db_access_for_all_tests(db): + pass diff --git a/example_project/sample/__init__.py b/example_project/core/__init__.py similarity index 100% rename from example_project/sample/__init__.py rename to example_project/core/__init__.py diff --git a/example_project/core/admin.py b/example_project/core/admin.py new file mode 100644 index 0000000..40edf78 --- /dev/null +++ b/example_project/core/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from .models import Project, Membership, Issue + + +admin.site.register(Project) +admin.site.register(Membership) +admin.site.register(Issue) diff --git a/example_project/core/apps.py b/example_project/core/apps.py new file mode 100644 index 0000000..5ef1d60 --- /dev/null +++ b/example_project/core/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = "core" diff --git a/example_project/core/migrations/0001_initial.py b/example_project/core/migrations/0001_initial.py new file mode 100644 index 0000000..d516359 --- /dev/null +++ b/example_project/core/migrations/0001_initial.py @@ -0,0 +1,109 @@ +# Generated by Django 3.1.1 on 2020-09-18 11:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Membership", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ], + ), + migrations.CreateModel( + name="Project", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=128)), + ("description", models.TextField(blank=True, default="")), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="owner", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "members", + models.ManyToManyField( + through="core.Membership", to=settings.AUTH_USER_MODEL + ), + ), + ], + ), + migrations.AddField( + model_name="membership", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="core.project" + ), + ), + migrations.AddField( + model_name="membership", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + migrations.CreateModel( + name="Issue", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=128)), + ("description", models.TextField(blank=True, default="")), + ( + "assigned_to", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issues_assigned", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issues_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/example_project/sample/migrations/__init__.py b/example_project/core/migrations/__init__.py similarity index 100% rename from example_project/sample/migrations/__init__.py rename to example_project/core/migrations/__init__.py diff --git a/example_project/core/models.py b/example_project/core/models.py new file mode 100644 index 0000000..376196c --- /dev/null +++ b/example_project/core/models.py @@ -0,0 +1,32 @@ +from django.db import models +from django.contrib.auth.models import User + + +class Project(models.Model): + name = models.CharField(max_length=128) + description = models.TextField(default="", blank=True) + members = models.ManyToManyField(User, through="Membership") + created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name="owner") + + def get_absolute_url(self): + return reverse("project_detail", kwargs={"pk": self.pk}) + + +class Membership(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + project = models.ForeignKey(Project, on_delete=models.CASCADE) + + +class Issue(models.Model): + name = models.CharField(max_length=128) + description = models.TextField(default="", blank=True) + assigned_to = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="issues_assigned", + ) + created_by = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="issues_created" + ) diff --git a/example_project/core/templates/core/project_detail.html b/example_project/core/templates/core/project_detail.html new file mode 100644 index 0000000..1bc01c8 --- /dev/null +++ b/example_project/core/templates/core/project_detail.html @@ -0,0 +1,20 @@ +{% extends 'bulma/base.html' %} +{% load cancan_tags %} + +{% block title %}{{object.name}}{% endblock %} + +{% block content %} +

{{object.name}}

+

{{object.description}}

+
+ +Back + +{% if ability|can:"change"|subject:object %} + Edit +{% endif %} +{% if ability|can:"delete"|subject:object %} + Delete +{% endif %} + +{% endblock content %} \ No newline at end of file diff --git a/example_project/core/templates/core/project_form.html b/example_project/core/templates/core/project_form.html new file mode 100644 index 0000000..d92e462 --- /dev/null +++ b/example_project/core/templates/core/project_form.html @@ -0,0 +1,16 @@ +{% extends 'bulma/base.html' %} +{% load bulma_tags %} +{% load cancan_tags %} + +{% block title %}Project Form{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {{ form|bulma }} +
+ +
+ +
+{% endblock content %} \ No newline at end of file diff --git a/example_project/core/templates/core/project_list.html b/example_project/core/templates/core/project_list.html new file mode 100644 index 0000000..dd1eff4 --- /dev/null +++ b/example_project/core/templates/core/project_list.html @@ -0,0 +1,40 @@ +{% extends 'bulma/base.html' %} +{% load cancan_tags %} + +{% block title %}Your projects{% endblock %} + +{% block content %} +

Your projects

+ +{% endblock content %} \ No newline at end of file diff --git a/example_project/core/templates/home.html b/example_project/core/templates/home.html new file mode 100644 index 0000000..1fa0b87 --- /dev/null +++ b/example_project/core/templates/home.html @@ -0,0 +1,7 @@ +{% extends 'bulma/base.html' %} + +{% block title %}{{object.name}}{% endblock %} + +{% block content %} +

django-cancan example project. Please log-in to continue

+{% endblock content %} \ No newline at end of file diff --git a/example_project/core/tests.py b/example_project/core/tests.py new file mode 100644 index 0000000..01d03b2 --- /dev/null +++ b/example_project/core/tests.py @@ -0,0 +1,42 @@ +from django.contrib.auth.models import AnonymousUser, User +from cancan.middleware import CanCanMiddleware +from core.views import ProjectListView +from core.models import Project + + +def test_login_required_for_projects_page(rf): + request = rf.get("/projects/") + request.user = AnonymousUser() + CanCanMiddleware().process_request(request) + response = ProjectListView.as_view()(request) + assert response.status_code == 302 + + +def test_user_can_see_his_own_projects(rf): + alice = User.objects.create(username="alice") + bob = User.objects.create(username="bob") + + project1 = Project.objects.create(name="Project 1", created_by=alice) + project2 = Project.objects.create(name="Project 2", created_by=bob) + + request = rf.get("/projects/") + request.user = alice + CanCanMiddleware().process_request(request) + response = ProjectListView.as_view()(request) + assert response.status_code == 200 + assert response.context_data["object_list"].count() == 1 + + +def test_admin_can_see_his_own_projects(rf): + alice = User.objects.create(username="alice", is_superuser=True) + bob = User.objects.create(username="bob") + + project1 = Project.objects.create(name="Project 1", created_by=alice) + project2 = Project.objects.create(name="Project 2", created_by=bob) + + request = rf.get("/projects/") + request.user = alice + CanCanMiddleware().process_request(request) + response = ProjectListView.as_view()(request) + assert response.status_code == 200 + assert response.context_data["object_list"].count() == 2 diff --git a/example_project/core/views.py b/example_project/core/views.py new file mode 100644 index 0000000..27e5bc3 --- /dev/null +++ b/example_project/core/views.py @@ -0,0 +1,62 @@ +from django.shortcuts import render +from django.urls import reverse +from django.contrib.auth.mixins import UserPassesTestMixin +from django.views.generic import ListView, DetailView, TemplateView +from django.views.generic.edit import CreateView, UpdateView +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth.middleware import AuthenticationMiddleware +from .models import Project + + +class HomeView(TemplateView): + template_name = "home.html" + + +class ProjectListView(UserPassesTestMixin, ListView): + model = Project + + def get_queryset(self): + return self.request.ability.queryset_for("view", self.model) + + def test_func(self): + return self.request.ability.can("view", self.model) + + +class ProjectDetailView(DetailView): + model = Project + + def get_queryset(self): + return self.request.ability.queryset_for("view", self.model) + + def test_func(self): + return self.request.ability.can("view", self.get_object()) + + +class ProjectCreateView(UserPassesTestMixin, CreateView): + model = Project + fields = ["name", "description"] + + def test_func(self): + return self.request.ability.can("add", self.model) + + def form_valid(self, form): + form.instance.created_by = self.request.user + return super().form_valid(form) + + def get_success_url(self): + return reverse("project_detail", args=(self.object.id,)) + + +class ProjectUpdateView(UserPassesTestMixin, UpdateView): + model = Project + fields = ["name", "description"] + + def test_func(self): + return self.request.ability.can("change", self.get_object()) + + def form_valid(self, form): + form.instance.created_by = self.request.user + return super().form_valid(form) + + def get_success_url(self): + return reverse("project_detail", args=(self.self.get_object().id,)) diff --git a/example_project/manage.py b/example_project/manage.py index 1b243ab..9e94ce3 100755 --- a/example_project/manage.py +++ b/example_project/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +18,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/example_project/pytest.ini b/example_project/pytest.ini new file mode 100644 index 0000000..1be48f9 --- /dev/null +++ b/example_project/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +DJANGO_SETTINGS_MODULE = settings +python_classes = Test* *Tests +python_files = tests.py tests_*.py *_tests.py \ No newline at end of file diff --git a/example_project/sample/admin.py b/example_project/sample/admin.py deleted file mode 100644 index 730ccdb..0000000 --- a/example_project/sample/admin.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.contrib import admin -from .models import TodoItem - - -admin.site.register(TodoItem) diff --git a/example_project/sample/apps.py b/example_project/sample/apps.py deleted file mode 100644 index f85f94a..0000000 --- a/example_project/sample/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class SampleConfig(AppConfig): - name = 'sample' diff --git a/example_project/sample/migrations/0001_initial.py b/example_project/sample/migrations/0001_initial.py deleted file mode 100644 index e31bdc0..0000000 --- a/example_project/sample/migrations/0001_initial.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.1 on 2020-08-27 15:20 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='TodoItem', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=128)), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/example_project/sample/models.py b/example_project/sample/models.py deleted file mode 100644 index 0f07bb9..0000000 --- a/example_project/sample/models.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.db import models -from django.contrib.auth.models import User -# Create your models here. - - -class TodoItem(models.Model): - name = models.CharField(max_length=128) - created_by = models.ForeignKey(User, on_delete=models.CASCADE) - - def __str__(self): - return f'#{self.id} {self.name} (by {self.created_by.username})' diff --git a/example_project/sample/templates/sample/todoitem_detail.html b/example_project/sample/templates/sample/todoitem_detail.html deleted file mode 100644 index 0c02334..0000000 --- a/example_project/sample/templates/sample/todoitem_detail.html +++ /dev/null @@ -1,12 +0,0 @@ -{% if user.is_authenticated %} -

Welcome, {{ user.get_username }}. Thanks for logging in.

-{% else %} -

Welcome, new user. Please log in.

-{% endif %} - -

TODO item #{{object.id}}

- -

{{object.name}}

-

by {{object.created_by.username}}

- -

Back

diff --git a/example_project/sample/templates/sample/todoitem_list.html b/example_project/sample/templates/sample/todoitem_list.html deleted file mode 100644 index 33f7b48..0000000 --- a/example_project/sample/templates/sample/todoitem_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% if user.is_authenticated %} -

Welcome, {{ user.get_username }}. Thanks for logging in.

-{% else %} -

Welcome, new user. Please log in.

-{% endif %} - -

Todo List

- - diff --git a/example_project/sample/tests.py b/example_project/sample/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/example_project/sample/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/example_project/sample/views.py b/example_project/sample/views.py deleted file mode 100644 index b637ab3..0000000 --- a/example_project/sample/views.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.shortcuts import render -from django.contrib.auth.mixins import PermissionRequiredMixin -from django.views.generic import ListView, DetailView -from .models import TodoItem - -from django.contrib.auth.backends import ModelBackend -from django.contrib.auth.middleware import AuthenticationMiddleware - - -class TodoIndexView(ListView): - model = TodoItem - - def get_query_set(): - qs = self.request.user.accessible_query_set(TodoItem) - return qs - - -class TodoDetailView(PermissionRequiredMixin, DetailView): - queryset = TodoItem.objects.all() - - def has_permission(self): - obj = self.get_object() - return self.request.user.can('view', obj) diff --git a/example_project/settings.py b/example_project/settings.py index 55a89d4..2fe5dc7 100644 --- a/example_project/settings.py +++ b/example_project/settings.py @@ -20,7 +20,7 @@ # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve(strict=True).parent -CANCAN_MODULE_PATH = abspath(BASE_DIR, '..') +CANCAN_MODULE_PATH = abspath(BASE_DIR, "..") sys.path.insert(0, CANCAN_MODULE_PATH) @@ -28,7 +28,7 @@ # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = '*c+8wbdcba%@-kszf7s+-q^@=g#f+gj4xw^=a0ab%o*lnd%vkd' +SECRET_KEY = "*c+8wbdcba%@-kszf7s+-q^@=g#f+gj4xw^=a0ab%o*lnd%vkd" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -38,55 +38,57 @@ # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'cancan', - 'sample' + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "bulma", + "cancan", + "core", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'cancan.middleware.CanCanMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "cancan.middleware.CanCanMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'urls' +ROOT_URLCONF = "urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "cancan.context_processors.abilities", ], }, }, ] -WSGI_APPLICATION = 'wsgi.application' +WSGI_APPLICATION = "wsgi.application" # Database # https://docs.djangoproject.com/en/3.1/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", } } @@ -113,9 +115,9 @@ # Internationalization # https://docs.djangoproject.com/en/3.1/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -124,12 +126,10 @@ USE_TZ = True -CANCAN = { - 'ABILITIES': 'abilities.declare_abilities' -} +CANCAN = {"ABILITIES": "abilities.declare_abilities"} # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.1/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" diff --git a/example_project/urls.py b/example_project/urls.py index 1daa81e..8b0b457 100644 --- a/example_project/urls.py +++ b/example_project/urls.py @@ -1,25 +1,22 @@ -"""example_project URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/3.1/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" from django.contrib import admin from django.urls import include, path -from sample.views import TodoIndexView, TodoDetailView +from django.views.generic.base import RedirectView + +import core.views urlpatterns = [ - path('admin/', admin.site.urls), - path('accounts/', include('django.contrib.auth.urls')), - path('', TodoIndexView.as_view()), - path('todo//', TodoDetailView.as_view()), + path("admin/", admin.site.urls), + path("accounts/profile/", RedirectView.as_view(url="/projects")), + path("accounts/", include("django.contrib.auth.urls")), + path("", core.views.HomeView.as_view(), name="home"), + path("projects/", core.views.ProjectListView.as_view(), name="project_list"), + path("projects/new/", core.views.ProjectCreateView.as_view(), name="project_new"), + path( + "projects//", core.views.ProjectDetailView.as_view(), name="project_detail" + ), + path( + "projects//edit/", + core.views.ProjectUpdateView.as_view(), + name="project_edit", + ), ] diff --git a/example_project/wsgi.py b/example_project/wsgi.py index 5bb0228..00d7110 100644 --- a/example_project/wsgi.py +++ b/example_project/wsgi.py @@ -11,7 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', - 'settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") application = get_wsgi_application() diff --git a/manage.py b/manage.py index 7f02e57..62f307b 100755 --- a/manage.py +++ b/manage.py @@ -3,8 +3,7 @@ import sys if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", - "cancan.testapp.testsettings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cancan.testapp.testsettings") from django.core.management import execute_from_command_line diff --git a/setup.py b/setup.py index 76fe14e..54ede14 100644 --- a/setup.py +++ b/setup.py @@ -14,23 +14,23 @@ url="https://github.com/pgorecki/django-cancan", packages=setuptools.find_packages(), classifiers=[ - 'Development Status :: 2 - Pre-Alpha', - 'Environment :: Web Environment', - 'Framework :: Django', - 'Framework :: Django :: 2.2', - 'Framework :: Django :: 3.0', - 'Framework :: Django :: 3.1', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3 :: Only', - 'Topic :: Internet :: WWW/HTTP', + "Development Status :: 2 - Pre-Alpha", + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 2.2", + "Framework :: Django :: 3.0", + "Framework :: Django :: 3.1", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Internet :: WWW/HTTP", ], - python_requires='>=3.6', + python_requires=">=3.6", )