diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks new file mode 100644 index 0000000..b55ad32 --- /dev/null +++ b/.ripwire_quality_acks @@ -0,0 +1,39 @@ +# ripwire quality acks v1 — written by --quality-ack; a finding stays suppressed until it worsens past its acked magnitude +# format: ack <16-hex-key> [cid=<16-hex-content-id>] [by=] — one per line, kept SORTED by (kind,key) on every write (merge-friendly) +ack duplication 07b21eccba4b853f 90 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication 0808552a2cb156e2 64 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication 1c54979316d47755 59 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication 1ebf7141892dce85 59 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication 36c53d31427d737f 77 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication 36dd407bf7995740 61 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication 43ffa97050be9c59 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate. +ack duplication 46611f8550817a04 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate. +ack duplication 58bb2dbc45626a89 77 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication 5ce836b9523750f3 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate. +ack duplication 60368ec2cee5e29e 21 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate. +ack duplication 62c88879e07b9e3b 73 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate. +ack duplication 677faf5f36583e91 84 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication 68c7d3f08353288f 207 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication 6feffa5e3876b705 53 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication 72f5e86186615047 59 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication 760e5582f9cac278 38 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication 764331dacd10a89e 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate. +ack duplication 816088d74742a3bd 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate. +ack duplication 85a85b24c763964c 88 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication 8c9a9e87785f4880 55 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication 95149d1a0d230890 88 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication 976339e40ee6a2cb 44 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication ae5d16bb65bfd38b 84 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication b4969500bb084d10 77 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate. +ack duplication bc3635ebb8739295 84 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication bdfe7d60141f2ce6 73 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate. +ack duplication be4e5df94b1b82e5 100 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication c1c4b99cde75e374 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate. +ack duplication c8109dee754a200e 88 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication c9f7a6966acae775 48 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication cbb16dac41720d6a 88 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication cfb7f7a3a970d0b3 63 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication d0993f4aa7ad017b 81 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate. +ack duplication e4d6ebd79eb27cc3 74 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced. +ack duplication efc7a9030246fb5d 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate. +ack short-horizon-churn 89776c50b7500989 4 cid=4c88db074226651a Intentional authentication backend replacement to enforce recorded session validity; recent-edit churn is expected for this setting. diff --git a/.ripwire_quality_baseline b/.ripwire_quality_baseline new file mode 100644 index 0000000..bffbb8f --- /dev/null +++ b/.ripwire_quality_baseline @@ -0,0 +1,5057 @@ +# ripwire quality baseline v6 — regenerate with --quality-baseline; do not hand-edit +producer 6ba6bc71fb56e24b994359ee76e73cdc590f0c37779cbed0edef299c36756a40 +head 423d4cf40eec77e47975acad44c28c2e15f4594a +dirty 1 +absorbed 4 +ccx 4b733c4d83b07 0 +ccx 23f7b60bac46e6 0 +ccx 3db89571c08346 0 +ccx 1b18ff4bbe15dc8 0 +ccx 2340a4830b3aea6 0 +ccx 24f99e084d8eac7 0 +ccx 25992276d82d57d 0 +ccx 28c515cc9cf15ea 0 +ccx 292e78225cb9e42 0 +ccx 33b850c282de191 1 +ccx 3d91ed95503b07c 0 +ccx 404b9670f3a703b 0 +ccx 43c9282165db6d5 0 +ccx 53a30a545ebbaa8 0 +ccx 567a6c2fb1b14e3 0 +ccx 60be78b537264df 0 +ccx 6434baca4e727a3 0 +ccx 6593a5202605144 0 +ccx 6fa75d99a22b8f8 0 +ccx 75ca46ce0ff4da0 0 +ccx 7b978a659fba30c 0 +ccx 7cab3f5048545bb 0 +ccx 7ecd582e890ea31 0 +ccx 827933c0d01af9f 0 +ccx 8528c5691f65b73 0 +ccx 85e190a60167712 1 +ccx 8819a45d3577c0d 0 +ccx 8cfc26a65c57400 0 +ccx 90af57aa911e8c8 0 +ccx 921b5208a7d7443 0 +ccx 9290bff224c908f 0 +ccx 96de467da885077 0 +ccx 9ca30156b74ccee 0 +ccx 9d3f23b15ca9b60 0 +ccx a366a6cfe2d6884 0 +ccx b42253871667dfe 0 +ccx b50f1f49cd3855e 0 +ccx b516b97cb14b102 13 +ccx babbb34b92b7a3c 1 +ccx baff7355e29d862 2 +ccx c17f5e8b96c68e7 0 +ccx c7aa4beae78784c 1 +ccx cc857a81514720b 0 +ccx ce934e313754b1f 1 +ccx d2cc7984bc1c193 6 +ccx d2f8bf6bd734dbe 0 +ccx db814bc584f0227 0 +ccx dfda1f29948b476 0 +ccx e287345a1da751d 0 +ccx e76fa62254196bb 0 +ccx 10ab7d01d10ccbe2 0 +ccx 10d0776f5dc84500 0 +ccx 1102ee13c3f287bc 0 +ccx 119e3a9de1579038 0 +ccx 11b9cd4c327155e8 0 +ccx 11cdabaaa36de566 1 +ccx 11e1842b0846e3c2 1 +ccx 121769c14e306576 0 +ccx 123ef12c5471047c 0 +ccx 12d781289b1f6537 1 +ccx 130120439ca7e74e 0 +ccx 13e98b0aac2ac45a 0 +ccx 1439d5906edb93a5 0 +ccx 153e1fedb7354bc2 0 +ccx 1544b3bf28bbba4d 0 +ccx 15cd8d11c115e37d 0 +ccx 16b12a8a9877f141 0 +ccx 1706f0f1d4b7d5aa 0 +ccx 1784741bfa7af1fd 0 +ccx 190843bf16d81bd4 2 +ccx 1a3d08994f90d6f9 0 +ccx 1a8c427d3dec251d 0 +ccx 1a99603a2e88d654 0 +ccx 1aefd4b805333268 0 +ccx 1c1f924943890c8e 0 +ccx 1c470b2e35f931bf 0 +ccx 1c6db503caf6a152 0 +ccx 1cf5097d9b16f9b0 0 +ccx 1d00916cd2964c35 1 +ccx 1d08599882d7cfe7 0 +ccx 1d1c8eb3ae5168fe 0 +ccx 1d2fa22ed4ee9ff2 0 +ccx 1d5a11c7d1edeb11 0 +ccx 1db269061a925799 0 +ccx 1e539f3e54012138 0 +ccx 1e6ca46d5635c401 0 +ccx 1e8a64d9423479a1 0 +ccx 1ec2fb826c707c29 0 +ccx 1f2b736eb17cf738 0 +ccx 1fc0fedd54c8e75b 0 +ccx 20305a32e94c6e20 0 +ccx 209b66ed45486485 4 +ccx 215d259a676f1c6b 0 +ccx 21b214aba3517cf9 0 +ccx 21dbd83072a7a2b0 0 +ccx 220a016c123269d2 0 +ccx 22f146b9d55161f5 0 +ccx 233ed227bd9ad4ec 1 +ccx 23a4ccb986e9c241 0 +ccx 23b429d4f8393438 0 +ccx 242bbb0e5b9ec108 0 +ccx 242d896b0833bcf6 0 +ccx 2449a169abfc5709 1 +ccx 248d8aea5119d112 0 +ccx 24d563a0bd0284ee 0 +ccx 24d61732a772f6ce 0 +ccx 258ed6b3635d219f 0 +ccx 25b33c65ec17c605 0 +ccx 265eeb107f4f7037 0 +ccx 26600eca83f91cdc 3 +ccx 2694ccc9cd9c2e4a 0 +ccx 26f2906b4561e5ed 0 +ccx 270e54f53f0dec25 0 +ccx 2765d6c249664ab6 0 +ccx 27af60e478606323 0 +ccx 27f05b6efba965c8 0 +ccx 27fcbd42c188ec3e 0 +ccx 28a6e8c239e04047 0 +ccx 2909b7f6c9f5d18d 0 +ccx 29167175f22af761 0 +ccx 297a4160638db8fd 0 +ccx 29b64a559ea4ffff 0 +ccx 29d1bc2a36afc76d 0 +ccx 29f5a11bbf71babc 0 +ccx 2a14568b12903218 0 +ccx 2a69b5e495fafcb6 0 +ccx 2a9e43e7514907ba 0 +ccx 2ae26defd322202f 0 +ccx 2afac393e3eb18c6 0 +ccx 2b3b72e3b3627a3a 1 +ccx 2b7f42fc1c74594d 0 +ccx 2b81eacbca431848 0 +ccx 2c77117a0e84cfe1 0 +ccx 2d4f0807a4377e66 0 +ccx 2d6194ec9f73238e 0 +ccx 2d8466f6954e126e 0 +ccx 2d96edc3799baa0e 0 +ccx 2dd16cc3aa6f6523 0 +ccx 2e0b841baad1468e 0 +ccx 2e395ab5adfaffe4 0 +ccx 2e8b4e73d01cda1d 0 +ccx 2e8f98f9891a8cba 0 +ccx 2ec94c46492bdf4e 0 +ccx 2ee347526cbe8582 4 +ccx 2f0b87d442d670ab 0 +ccx 2f379cdbd5b7baad 0 +ccx 2f48393189526db5 0 +ccx 2f750d28b00ff12a 0 +ccx 2f8af7800010622b 1 +ccx 306ff4fa969c2b5e 0 +ccx 30823f7b3a383b0b 1 +ccx 30b49386bfe531be 0 +ccx 30b5d92358dcd253 0 +ccx 30fdfead3bc13677 0 +ccx 323f04fd5a7a671a 0 +ccx 324c715b3ef59fb5 0 +ccx 334ac2095aeeffb5 0 +ccx 3440f8d589cae291 0 +ccx 3455adbedae84514 0 +ccx 345a2fdfacb84efa 1 +ccx 34a9957d1f0f0b52 0 +ccx 3529f53b490c717a 0 +ccx 3599e84ad64ea3a0 0 +ccx 35d9e976f25eecfb 0 +ccx 3677e09eb4196cd4 0 +ccx 36aff0a235db6e64 0 +ccx 36eb5585be49ea31 0 +ccx 374dc0d9439e660e 0 +ccx 378070cac6c29d8a 0 +ccx 37d1819c814b6e48 0 +ccx 37e6c1a9572eed09 0 +ccx 37f14128572d051e 0 +ccx 37fde5021a0194e9 1 +ccx 3817c48d6d009d40 0 +ccx 3844be8089f457b2 0 +ccx 386a103486cff027 0 +ccx 388245783c0938fa 0 +ccx 38b9413e0cbbaac0 0 +ccx 38d2574e03270d77 0 +ccx 39aa49c948c80691 0 +ccx 39dc70474f1885ae 0 +ccx 39e896bec2ab77c9 0 +ccx 3a01c0eb5dcd7613 0 +ccx 3ac893ff638f771b 0 +ccx 3ad308b61b8f874e 1 +ccx 3b670e3e6581709f 0 +ccx 3b6d5a0ea9a511e9 0 +ccx 3bb6aca50f06a606 0 +ccx 3c3d72419cba9309 0 +ccx 3ce01651a41aafb6 0 +ccx 3d13afc5e29e371a 0 +ccx 3d8282d048efcc77 0 +ccx 3d903cc2ced5fe66 0 +ccx 3e185994d574529f 0 +ccx 3eede00189a7a15f 0 +ccx 3f6dfe71df011044 2 +ccx 3fd29f21cbad3628 0 +ccx 3fd493d0cfa3199c 0 +ccx 403c374008b3781f 0 +ccx 4051d8f6c2af839f 0 +ccx 410fa58f10ab2614 0 +ccx 412954cd14f67658 0 +ccx 412e3e27d4215e06 2 +ccx 41673bb8d8375d55 0 +ccx 418b8560d8501615 0 +ccx 419553ea1dcb7cbb 0 +ccx 41c0961bc8c2cab7 0 +ccx 41d3ef4759cf2df9 0 +ccx 41d5fcfae22584c4 0 +ccx 420f8a970c0221f5 0 +ccx 42b8290f3fed3450 0 +ccx 42c814d98094dbb7 0 +ccx 430cba9d0ec3ab56 0 +ccx 437e4d914b709dbe 0 +ccx 4425f307731f789f 0 +ccx 447dd766bab76e3d 0 +ccx 44c0fb3e0547cf06 0 +ccx 44e4a85260ed66f3 1 +ccx 4581af238c881954 2 +ccx 45df799f4c412c1e 0 +ccx 467a56bed6f21356 0 +ccx 468e7a55aa64e481 0 +ccx 468f6db3ee1d572b 0 +ccx 46fbd074054bea23 0 +ccx 470d78a9487c1c79 0 +ccx 472324ee9732f077 0 +ccx 473790676e54eb23 0 +ccx 479d7c2ae4e70e80 1 +ccx 47f97c0fb37c8568 0 +ccx 47fa33217335c644 0 +ccx 482cd3901a33f7da 0 +ccx 4849325cb38239ae 0 +ccx 48eb495d1a981e3b 0 +ccx 492eb95e1d2c9566 0 +ccx 49ce64a0c7a6ba56 0 +ccx 4a5b1dd8e915b277 0 +ccx 4ad0bfd06eca3b10 0 +ccx 4afaa3ee804f31a3 0 +ccx 4c031399d70c63d6 0 +ccx 4c2c8773e6afbfc3 3 +ccx 4c2caaf3d6ca8c78 0 +ccx 4ca0617355962ae6 0 +ccx 4ca9fa48f6567768 0 +ccx 4cd5d6d5bc533efc 0 +ccx 4d1f25b9f88ce40a 0 +ccx 4dd0e56fa6b1cd26 0 +ccx 4eb915efc09067e9 0 +ccx 4f0a11cd12ebdd64 0 +ccx 4f1eeefcc6361836 0 +ccx 5016e2cec581b778 0 +ccx 5098ceb6ba93f0f1 0 +ccx 513a82879f6988a3 0 +ccx 528f29df5a342dec 0 +ccx 532d4763ee4f0d51 0 +ccx 5349e49cedd311d3 0 +ccx 539c00ef45a998b6 0 +ccx 554719c9558ee90c 0 +ccx 5567796a9df49bf1 1 +ccx 5690a7e6f4b931fa 0 +ccx 56d3eb4a00f6de55 0 +ccx 56eccd261ee32406 0 +ccx 57118e22b1dee7aa 9 +ccx 5738d7b2ce7e1fb1 0 +ccx 57aaa7dc54b71ee5 0 +ccx 57cae2ed030dc221 1 +ccx 5825699eb9713c9b 0 +ccx 5844b083ab372a93 0 +ccx 58b6201c2abb3c2d 0 +ccx 58c2008305b0afe0 2 +ccx 58ceb8247ac5a5e8 0 +ccx 596a40ae7adf3b66 0 +ccx 597bcfac2e1bfc60 0 +ccx 59fafb2628d5e22c 0 +ccx 5b7d514864a7fe74 0 +ccx 5ba349d3f9dbe981 0 +ccx 5ba413ec871b1a05 0 +ccx 5bcbc43e0bfc1c04 0 +ccx 5bef2751b97cdaf8 0 +ccx 5c427223e4f7bd31 0 +ccx 5c62bc4336a92380 0 +ccx 5c976139ae63e37e 1 +ccx 5cb29ea4b16ee5b4 0 +ccx 5cff4150fa39e9ab 1 +ccx 5d8dea77474eef46 1 +ccx 5dc5ca231ce7af8c 0 +ccx 5ddce9f50b0c275d 0 +ccx 5e6955ad62fc35dd 3 +ccx 5f4c2b1da49767c2 6 +ccx 5faf45317652eb97 0 +ccx 5fca1dab3eb84ff6 0 +ccx 5fcec82716ef1539 0 +ccx 60ae7b12db9b9c97 0 +ccx 60fdd1aa679748b0 0 +ccx 610775902254026b 0 +ccx 61fe78aa31fda57a 8 +ccx 62118ff9d5625689 0 +ccx 62c4c7c808ddb517 0 +ccx 63d5ec100a00a495 0 +ccx 643e391062d5ad98 0 +ccx 64cee7e94959c37e 0 +ccx 65401e97a7b3dde3 9 +ccx 6619eb15218ab6f4 0 +ccx 662cc3aa77fb9b46 0 +ccx 670c6a44b9bdada6 1 +ccx 672566a3610ffd5f 0 +ccx 67a27cf55608f473 0 +ccx 681cdc3561eb7d64 0 +ccx 689179ee148b90d7 0 +ccx 68e893e8f3d71fe9 0 +ccx 690e7d5a20a3fe48 0 +ccx 696611d56fdfeda5 0 +ccx 6a437d0c0e83cacb 0 +ccx 6b5abc249db062e7 1 +ccx 6ba0e82f39ba4f6a 3 +ccx 6c08a8d51137d60d 0 +ccx 6c0907eac176dd5f 2 +ccx 6cbc12bcc60a4e8d 0 +ccx 6ce6bd5b9c4a630f 0 +ccx 6d661a0458d987d3 0 +ccx 6d89a0375f525b9b 1 +ccx 6e594189eafe0408 1 +ccx 6e7d533058e61f70 0 +ccx 6e8de06cc7d7c1b9 0 +ccx 6e9f1d938c50aeaa 0 +ccx 6ed1fd271b188d46 0 +ccx 6f12044892549b6a 0 +ccx 6f6b77ffc341ac63 0 +ccx 6f92d0e9fea66324 0 +ccx 701d98e1ee6bcead 0 +ccx 703eae2147b6debd 3 +ccx 709961d34ae00031 0 +ccx 70acb27eb42344f9 0 +ccx 70f36ffdb4452f92 1 +ccx 718940229dd98516 0 +ccx 71d2cb7f1d1a951f 1 +ccx 72e0017a3e9b4ca1 0 +ccx 72ef52b7bd594829 1 +ccx 72f4fe268d6cb29a 0 +ccx 735d655f4fb0fd03 0 +ccx 737bc925aefcc6c5 0 +ccx 7385fecc2a9f3960 0 +ccx 73901c5b41eaae62 0 +ccx 73e074eadb44c9f7 0 +ccx 73e72ba081fc59e2 0 +ccx 73f11932d9093d6f 0 +ccx 7464bc75ffa4b7c6 0 +ccx 74798dc894205762 0 +ccx 752011234791fb28 0 +ccx 755a08e70176b633 0 +ccx 7594fbf007da4c74 0 +ccx 75c8bef4f03819be 0 +ccx 75eb9f6f072bdcb4 0 +ccx 766d82bfd86dd118 0 +ccx 76c1f41550b1b997 2 +ccx 76dc0bb4769da62d 0 +ccx 776b8b1eab8668f4 0 +ccx 7791b74c1fceaf2a 12 +ccx 77fbb3c024e536a6 0 +ccx 7869708e768f836d 0 +ccx 78af546e01ca37a8 0 +ccx 795d777e64164e3c 1 +ccx 79a9c21686684680 0 +ccx 7a2991b69512efe4 0 +ccx 7af16e2b108a072b 0 +ccx 7b38b69469da3083 0 +ccx 7bbbeff107c17bde 0 +ccx 7c0c8d55f5c503d0 0 +ccx 7cbebea3fdb2d0f2 0 +ccx 7d74b2ed6c067b53 0 +ccx 7e30b28a733bf314 0 +ccx 7e6fd84a59cb93e2 0 +ccx 7e865b52c71df10f 0 +ccx 7f09d0dc03338350 0 +ccx 7f0dfcb8e7044238 0 +ccx 7f303a84eac3193d 2 +ccx 7f4d61b6e5398157 0 +ccx 80916eb377aa30c0 0 +ccx 809f43336ad03a2a 2 +ccx 80e0733dd1dd8967 0 +ccx 8145a5f15c149225 0 +ccx 81bdfa9c95a47411 1 +ccx 81cb89a3777fa95b 0 +ccx 823b34f6ccc08503 0 +ccx 82789957ab0d60dd 0 +ccx 8344f0e335b7777f 0 +ccx 836124753f3d43ab 0 +ccx 83617b448ae4f5b9 0 +ccx 83bcd9fa7921e9d3 0 +ccx 841f41fa7a532342 1 +ccx 843711ddc2000a69 0 +ccx 8444bfbbeb4b82aa 0 +ccx 8448f217eadf0cb2 0 +ccx 84f0a5079e308d96 0 +ccx 85a1cf5cc0f384f6 0 +ccx 85a803fc71028fe4 0 +ccx 860c6bcc0adecb82 0 +ccx 861e750aa9a0a707 0 +ccx 86216d2ceafc4984 0 +ccx 8634268abbb0b083 0 +ccx 8642302fa250c337 0 +ccx 86432dad9ce7d6cb 1 +ccx 86a231892fd2185e 0 +ccx 86a3f7bdf62de7a0 0 +ccx 86bb4d97aa1153b2 0 +ccx 86d9ce774ac0e9b2 0 +ccx 873c2b8fb5cc9d7b 0 +ccx 8751c283bf5503b0 15 +ccx 882bbb80b4c33a2a 0 +ccx 8834877c9b15df09 0 +ccx 888095969ea9ab92 0 +ccx 89776c50b7500989 0 +ccx 89a3b60759c7a791 0 +ccx 89f9967795227eb9 0 +ccx 8a7b0fc3a462a5d6 0 +ccx 8a917b2e79abf10b 0 +ccx 8bfad7e73ac0394e 0 +ccx 8ce48499c2224772 0 +ccx 8d0b4c6c4e0b8287 0 +ccx 8d5b6014948b0f22 0 +ccx 8d74a3b2fe7eb966 0 +ccx 8d9a330126fc06e2 0 +ccx 8d9a862e3af62f77 0 +ccx 8de5991c8f804dc9 0 +ccx 8e8cf68def8d9e0e 0 +ccx 8ed2c4409443dda9 0 +ccx 8f9de2b50c6d0080 0 +ccx 8feab80bf0370f32 0 +ccx 8ff96a7fddf76be1 0 +ccx 910bfa248b2227af 0 +ccx 917b56d82c963db9 0 +ccx 917beb804a058565 0 +ccx 919157b8dd21ebc5 0 +ccx 91dcc2e5054d5bab 0 +ccx 925ba93869af880e 0 +ccx 92938b5bcf3574b7 0 +ccx 93323bfcca74ac92 0 +ccx 939d426bd40f34bb 0 +ccx 93b9d305982206ad 0 +ccx 93bf0fd6213a7b84 0 +ccx 9407348f6ee68f78 0 +ccx 947bae210f6a7031 0 +ccx 949ba1dba9a922ee 0 +ccx 94aad1a9ad691252 0 +ccx 954ed37fdd30474a 0 +ccx 956291fcc7f26916 0 +ccx 964723e6313d848e 0 +ccx 966a40fe1711ccf0 0 +ccx 96dfca7e24aaf25d 0 +ccx 9777dc82c98426b8 0 +ccx 977bf6fbb79350d3 0 +ccx 979f53be1f68fa7b 0 +ccx 97f4e74e9c4d232b 0 +ccx 97fb9fe5299fca74 0 +ccx 9854a9f84be0722e 0 +ccx 989c038c6c9c954b 10 +ccx 99054039062a6d56 0 +ccx 9a08845a5a2b39d5 0 +ccx 9aadf5d88db176de 0 +ccx 9b00b22bd2dfcdea 0 +ccx 9b15492f093e9680 0 +ccx 9b675b24201a7db1 0 +ccx 9c0e8638585adacf 0 +ccx 9c2fdbb657785b23 0 +ccx 9cf542d698798567 0 +ccx 9d0250cf760a1cb1 0 +ccx 9da180752e6d8e5c 0 +ccx 9db9edf9dc3ccc47 0 +ccx 9dfcef85a5056fc8 0 +ccx 9e1070f26a7961f5 0 +ccx 9ea88db302fadbad 0 +ccx 9ef916a9ee01d457 0 +ccx 9f4839d437dbdb32 0 +ccx 9fe93961fce54ce4 0 +ccx 9ff084684afeb19e 0 +ccx a02c8cbee597d352 0 +ccx a04d750c20c1a89a 0 +ccx a077984a0c213988 0 +ccx a0a05b5b3bae9287 0 +ccx a0cb16a743107fec 0 +ccx a0f0fea3368f2393 0 +ccx a103963d54dbd311 0 +ccx a17366facb51952d 0 +ccx a188cfc59cab8daf 0 +ccx a1b43eb494b1fcd0 0 +ccx a2723c0c456ffc48 0 +ccx a2750be13ac0ee82 0 +ccx a2947b425b951a72 0 +ccx a2c7a6bff645bd0b 0 +ccx a2ea72ba4256d1ad 0 +ccx a3135ee44b475bac 0 +ccx a3c2bb108cce8ba0 1 +ccx a3f0563f01c103d7 0 +ccx a41d39c5795ca771 0 +ccx a508a4b30b2f79a8 1 +ccx a525edb704ff846a 1 +ccx a5ae71540871bba8 0 +ccx a604a99ead07e8b7 6 +ccx a62599079371da08 0 +ccx a649d84498c126fb 0 +ccx a6575e6a24b93565 0 +ccx a7d38a0353d337b6 0 +ccx a9956363396fd78f 0 +ccx aa7daecde0716211 0 +ccx aa8df295f2993c33 0 +ccx aafe6cbcf5a00afa 0 +ccx ab7a128a7db81697 0 +ccx ab8c28087d812326 0 +ccx abb36acf86e05f44 0 +ccx abfbbb2620e0ae26 0 +ccx ac2dcd57d0e40382 1 +ccx ac5f9599e49c6015 0 +ccx ad36ead5dc5ed87e 0 +ccx adb4262180fbbfa3 0 +ccx aea683ac23a9c3c2 0 +ccx aee6e643327d448c 0 +ccx aee9bf52c500e4ed 0 +ccx af029cbd84ef0056 0 +ccx af2de214b51708e1 1 +ccx b0176088079c92c2 3 +ccx b02160df0862ef04 0 +ccx b0a2e27b2dbbb205 0 +ccx b0ccffd9ae0581de 0 +ccx b13dd85e39366f9b 0 +ccx b16c5ae4b8177443 0 +ccx b1ca8c69bcb29f4c 0 +ccx b25689d132f6a3aa 0 +ccx b43807588b3d388c 7 +ccx b550f7063b5e1658 0 +ccx b55f7472956eb256 0 +ccx b5ee57928f707a5b 0 +ccx b5fb452f795be5ae 0 +ccx b6774bf12793f446 0 +ccx b70a9e2dff98d690 0 +ccx b873090225df1db0 0 +ccx b8c3995c4bdabe95 0 +ccx b8e9148c718a83b2 0 +ccx b926ca3a7fe53ba2 0 +ccx b92a296420f44bb4 0 +ccx b999d958d4795300 0 +ccx b99d7ec7f2aaacc4 0 +ccx ba063e007e4a0089 0 +ccx ba0efed99d11207b 0 +ccx bac2ec39302668c7 3 +ccx bad8f1522ba80ebe 0 +ccx bafd9e14f2744298 0 +ccx bb14250915d7445f 0 +ccx bb4bef0b64fd8d48 0 +ccx bb70288b5c1a5ab3 0 +ccx bb774894bae68dfb 0 +ccx bb7a2eb4b3663ef8 0 +ccx bba7ed99af8f5b54 0 +ccx bbd0138ed7a0d350 0 +ccx bbee458ed08455f3 0 +ccx bc12021c4f16c7b1 0 +ccx bc2a33a973f536c4 0 +ccx bcb1ccbb00ea585f 0 +ccx bcdea3156f45a122 0 +ccx bd028205e65b668b 0 +ccx bd1086a51723f0a9 0 +ccx bd409f1d90d1ec7e 0 +ccx bd99db1ff250e606 0 +ccx bda35bf5d19afe35 0 +ccx bde9492df89e79bb 0 +ccx bdfa2256a1243766 0 +ccx be1c296b4103541a 0 +ccx be5051b47f524a38 0 +ccx be608bd98b316613 0 +ccx be6f1003b9147c1e 0 +ccx be9f5fbfbcf49f3b 0 +ccx beab83a2a041144e 0 +ccx bfc39e13eac109b5 0 +ccx bfe88aff0d1cf1ac 0 +ccx c04451c38eea2f3d 0 +ccx c0f637cf0b098e2e 16 +ccx c12ea4bb6c4307ad 0 +ccx c141ab01fc2a3cd6 0 +ccx c1b86e1466d682c2 0 +ccx c1d80e01a94fb0d5 0 +ccx c2b7d06e06237be2 0 +ccx c372db7ad947ef02 6 +ccx c37c3f7fe77cffe5 0 +ccx c5ae5fa47a4cf64f 0 +ccx c5b0ee41b05acfb2 0 +ccx c60ec2d90df2762b 0 +ccx c6a6785521523225 5 +ccx c77b05daa4ff56ae 1 +ccx c7a2188f77229408 0 +ccx c83cc8417e632ce2 0 +ccx c853f415c89a1d6a 0 +ccx c85f0d56aa6253b5 0 +ccx c8c1c712735b0ccc 0 +ccx c90f6570abc63716 0 +ccx c9417f90820411a2 0 +ccx c972eb6e158540ea 0 +ccx c99075537de59d30 1 +ccx ca51003e7d5cf837 0 +ccx ca612ead4b39d244 0 +ccx cb0370a917abe0df 0 +ccx cb488f245b2795da 0 +ccx cba3c34ae22ec49b 0 +ccx cbaf49d9aa662ec8 0 +ccx cc256dd80b037384 0 +ccx cc329731922ce601 0 +ccx cc383f93cc4a1177 0 +ccx cc527f122cca2eee 0 +ccx cc71575aafbcac28 0 +ccx cd44a949d2c08a9d 0 +ccx cd514d3bf7b25bba 3 +ccx cdbea33e56e46c5a 0 +ccx ce20725a9f915d01 0 +ccx ce275778f4d4977d 0 +ccx ce288f34f8e6e4af 0 +ccx cf024f76b08afd38 5 +ccx cfdc0c8bc7aaaf12 0 +ccx d05a659360ba6f3c 0 +ccx d0dc0130bbaffff0 0 +ccx d1d5db6a15028a9b 0 +ccx d2085fbebccdffda 0 +ccx d25a3ce67a83b39b 0 +ccx d27009c85a85fc05 0 +ccx d2b8b85442a71925 0 +ccx d2d13b315fa7cda4 0 +ccx d3c8d6b28ec1e96c 0 +ccx d3f0565c6040da91 0 +ccx d3fcd81e343f10ef 0 +ccx d4885e4f3e20b47b 2 +ccx d491b0153cfde2d8 0 +ccx d4d8059fa01f1e3c 0 +ccx d4dce2d2b3a5855d 0 +ccx d4e70b54741103c0 0 +ccx d50d180e3f8249f7 0 +ccx d51295ab04b0d39c 0 +ccx d560adb4a23e2cff 0 +ccx d5b13308dc43b558 0 +ccx d5bb33cd7f8fba6b 0 +ccx d65071a443bfd4aa 0 +ccx d72258d6542408c2 0 +ccx d78a9a1b73a37bc2 0 +ccx d7bdfd13e38c53eb 0 +ccx d83135f37d441e8a 0 +ccx d85fa2a1d24a1d9f 0 +ccx d864f94da21ec08c 2 +ccx d8bec32aecbb5851 0 +ccx d8cb3bc23bf7e3c7 0 +ccx d8e1d53e3c403ae2 0 +ccx d900a7d57a7a6e51 0 +ccx d94841dd9e08985b 0 +ccx d9a72ef3652ab4bd 3 +ccx d9b34ca129cedb0a 0 +ccx da2f7f459c5ddf7f 0 +ccx da42d957244e0f81 0 +ccx da62006c9ee9330e 0 +ccx da950ddf8dfe0436 0 +ccx dafd603c2f091213 0 +ccx db6e9f0bb941444e 0 +ccx db7e32c39b011150 0 +ccx dbee05807e339e8d 0 +ccx dbf55aba3ae608cc 0 +ccx dc57c1548a19f6ef 0 +ccx dcd3b9c5600c74ac 0 +ccx dce93392eeee89dd 0 +ccx dd55181effa0995b 0 +ccx dd7f4ba2505ff33c 0 +ccx ddf05a9fda55b0c4 0 +ccx de0c720a86e29e55 0 +ccx de35fac2281b4eea 0 +ccx de6229cf181cd846 0 +ccx de6f49bdb8c48e7b 0 +ccx de9df27d11fd6d43 0 +ccx df0e0f92f2092438 0 +ccx df22835a99ccf5bc 0 +ccx e0d588512343fa21 0 +ccx e10010a21b629e66 0 +ccx e146ee1a4a342aac 0 +ccx e1b4c1103fc2ed5b 0 +ccx e23c2226eb4158af 0 +ccx e2699fd6a3fa1328 0 +ccx e2fe3265394778ce 0 +ccx e300b1faf6e1d012 1 +ccx e36cf8c9d9f1cd89 0 +ccx e378ef5afbb3e408 0 +ccx e3a90ae9514813f6 0 +ccx e3ad8a7c6e96972c 0 +ccx e3d2d3a21b154af6 0 +ccx e40b81d45cf6363d 1 +ccx e45f0d4802d2848a 0 +ccx e46ab7a2d342d4e1 0 +ccx e475358369fb36cd 0 +ccx e49178f48c0574fa 1 +ccx e4c073933f1e47b6 0 +ccx e51a8d8ddd82a50d 0 +ccx e53f1181d710f13f 0 +ccx e57a61300c415efd 0 +ccx e5eab62fa164f5df 0 +ccx e6378805498ae207 0 +ccx e65813db8d9b2bc6 0 +ccx e672f75e6728a41b 0 +ccx e6e4049fbb37155b 0 +ccx e7afc20b0204158a 0 +ccx e81adba88c94d542 0 +ccx e81be21ddfb4b2ca 0 +ccx e83f724256ae1c03 1 +ccx e8bf877e84fd93c8 0 +ccx e963d0798b6c2416 0 +ccx e98c2bc92448636c 3 +ccx ea67c8fe6f523c94 0 +ccx eaaca8031d102a8a 0 +ccx eabdb1c11c0b7344 0 +ccx eaf3758011d7fd20 0 +ccx eb21547e8fc5798d 0 +ccx eb59730bea76712d 0 +ccx ebd54b9559a8d1dc 0 +ccx ebe6c7d83a7b0b88 0 +ccx ec0a3b87d49de600 1 +ccx ec23757266779a3d 1 +ccx ec238b174e9bc2c2 0 +ccx ec571d26837759c3 0 +ccx ed0f421581be922c 0 +ccx ed51829342734599 0 +ccx ed5af324fbb452ce 0 +ccx edc3a81a5285678e 0 +ccx ee8b47838e145fcc 0 +ccx ee9fc2ad102cff33 2 +ccx ef0b1c5fa0b90ab9 0 +ccx ef1d53b8c44bbb67 0 +ccx ef29cc93418187ad 0 +ccx efb4a906769bc50a 0 +ccx f028f44bfc9220df 0 +ccx f03f21e4f1638e0f 0 +ccx f103a8f1cc586bbe 0 +ccx f148d5a58bdea0ef 0 +ccx f189dcfe4fda591e 0 +ccx f197817f969bf7c7 0 +ccx f1e3e5d2694a3263 0 +ccx f2b1cab4ba10688d 0 +ccx f2e357d4e331608a 0 +ccx f3522319ec73e6b5 0 +ccx f375a8b30995f676 0 +ccx f38296f6730d0380 0 +ccx f38d7d97db1cc3a5 3 +ccx f4af891e3663df17 0 +ccx f5da182a28999848 0 +ccx f6499fa9b56dd88d 0 +ccx f64f3b1838d23608 0 +ccx f69b15b433a0ebb1 0 +ccx f6cd3e68decce213 0 +ccx f6d09aaa879795f5 0 +ccx f740404a18ee9317 0 +ccx f83dbf2f26b3d9d6 0 +ccx f85e3c71bf15f382 0 +ccx f8ff1b999d5139e6 0 +ccx f90db1a16f62ee03 0 +ccx f953ae6dc1284559 0 +ccx f9710e2cab88cd14 0 +ccx f9bbc08faef1cd9a 0 +ccx f9f84d21d21eb570 0 +ccx fa47b525839d3e9b 0 +ccx fa6e14ca0bb22b76 0 +ccx fb57d0b51a01ea3d 0 +ccx fc41f933bfe74d4c 0 +ccx fc7f155243219c03 0 +ccx fd19ef11039692b7 0 +ccx fd619dcdcef1e43e 0 +ccx fde1a69149bde1e8 0 +ccx fe09c2eea1d66805 0 +ccx fe21950435627c9a 0 +ccx fe7c1a71bbacd6c7 0 +ccx fedbe7b3fffd467c 0 +ccx fef21dd5727bbb51 0 +ccx ffd1fc17b731e639 2 +ccx ffec8b3317b77318 0 +loc 4b733c4d83b07 4 +loc 23f7b60bac46e6 1 +loc 3db89571c08346 4 +loc 1b18ff4bbe15dc8 2 +loc 2340a4830b3aea6 4 +loc 24f99e084d8eac7 10 +loc 25992276d82d57d 11 +loc 28c515cc9cf15ea 2 +loc 292e78225cb9e42 11 +loc 33b850c282de191 8 +loc 3d91ed95503b07c 11 +loc 404b9670f3a703b 4 +loc 43c9282165db6d5 11 +loc 53a30a545ebbaa8 0 +loc 567a6c2fb1b14e3 3 +loc 60be78b537264df 2 +loc 6434baca4e727a3 11 +loc 6593a5202605144 4 +loc 6fa75d99a22b8f8 18 +loc 75ca46ce0ff4da0 11 +loc 7b978a659fba30c 11 +loc 7cab3f5048545bb 22 +loc 7ecd582e890ea31 11 +loc 827933c0d01af9f 10 +loc 8528c5691f65b73 11 +loc 85e190a60167712 5 +loc 8819a45d3577c0d 23 +loc 8cfc26a65c57400 95 +loc 90af57aa911e8c8 11 +loc 921b5208a7d7443 15 +loc 9290bff224c908f 11 +loc 96de467da885077 13 +loc 9ca30156b74ccee 2 +loc 9d3f23b15ca9b60 16 +loc a366a6cfe2d6884 11 +loc b42253871667dfe 11 +loc b50f1f49cd3855e 56 +loc b516b97cb14b102 44 +loc babbb34b92b7a3c 6 +loc baff7355e29d862 22 +loc c17f5e8b96c68e7 11 +loc c7aa4beae78784c 7 +loc cc857a81514720b 1 +loc ce934e313754b1f 10 +loc d2cc7984bc1c193 29 +loc d2f8bf6bd734dbe 3 +loc db814bc584f0227 10 +loc dfda1f29948b476 11 +loc e287345a1da751d 18 +loc e76fa62254196bb 11 +loc 10ab7d01d10ccbe2 2 +loc 10d0776f5dc84500 19 +loc 1102ee13c3f287bc 38 +loc 119e3a9de1579038 19 +loc 11b9cd4c327155e8 11 +loc 11cdabaaa36de566 10 +loc 11e1842b0846e3c2 5 +loc 121769c14e306576 4 +loc 123ef12c5471047c 2 +loc 12d781289b1f6537 13 +loc 130120439ca7e74e 11 +loc 13e98b0aac2ac45a 20 +loc 1439d5906edb93a5 19 +loc 153e1fedb7354bc2 11 +loc 1544b3bf28bbba4d 14 +loc 15cd8d11c115e37d 2 +loc 16b12a8a9877f141 8 +loc 1706f0f1d4b7d5aa 5 +loc 1784741bfa7af1fd 4 +loc 190843bf16d81bd4 7 +loc 1a3d08994f90d6f9 16 +loc 1a8c427d3dec251d 4 +loc 1a99603a2e88d654 6 +loc 1aefd4b805333268 3 +loc 1c1f924943890c8e 31 +loc 1c470b2e35f931bf 2 +loc 1c6db503caf6a152 35 +loc 1cf5097d9b16f9b0 11 +loc 1d00916cd2964c35 7 +loc 1d08599882d7cfe7 18 +loc 1d1c8eb3ae5168fe 11 +loc 1d2fa22ed4ee9ff2 11 +loc 1d5a11c7d1edeb11 4 +loc 1db269061a925799 29 +loc 1e539f3e54012138 7 +loc 1e6ca46d5635c401 11 +loc 1e8a64d9423479a1 11 +loc 1ec2fb826c707c29 18 +loc 1f2b736eb17cf738 3 +loc 1fc0fedd54c8e75b 1 +loc 20305a32e94c6e20 3 +loc 209b66ed45486485 26 +loc 215d259a676f1c6b 2 +loc 21b214aba3517cf9 1 +loc 21dbd83072a7a2b0 8 +loc 220a016c123269d2 8 +loc 22f146b9d55161f5 2 +loc 233ed227bd9ad4ec 6 +loc 23a4ccb986e9c241 1 +loc 23b429d4f8393438 17 +loc 242bbb0e5b9ec108 4 +loc 242d896b0833bcf6 0 +loc 2449a169abfc5709 7 +loc 248d8aea5119d112 11 +loc 24d563a0bd0284ee 16 +loc 24d61732a772f6ce 23 +loc 258ed6b3635d219f 11 +loc 25b33c65ec17c605 11 +loc 265eeb107f4f7037 7 +loc 26600eca83f91cdc 17 +loc 2694ccc9cd9c2e4a 84 +loc 26f2906b4561e5ed 40 +loc 270e54f53f0dec25 11 +loc 2765d6c249664ab6 9 +loc 27af60e478606323 16 +loc 27f05b6efba965c8 48 +loc 27fcbd42c188ec3e 7 +loc 28a6e8c239e04047 11 +loc 2909b7f6c9f5d18d 2 +loc 29167175f22af761 2 +loc 297a4160638db8fd 2 +loc 29b64a559ea4ffff 11 +loc 29d1bc2a36afc76d 11 +loc 29f5a11bbf71babc 14 +loc 2a14568b12903218 29 +loc 2a69b5e495fafcb6 3 +loc 2a9e43e7514907ba 2 +loc 2ae26defd322202f 4 +loc 2afac393e3eb18c6 14 +loc 2b3b72e3b3627a3a 16 +loc 2b7f42fc1c74594d 6 +loc 2b81eacbca431848 0 +loc 2c77117a0e84cfe1 2 +loc 2d4f0807a4377e66 37 +loc 2d6194ec9f73238e 11 +loc 2d8466f6954e126e 32 +loc 2d96edc3799baa0e 7 +loc 2dd16cc3aa6f6523 11 +loc 2e0b841baad1468e 10 +loc 2e395ab5adfaffe4 11 +loc 2e8b4e73d01cda1d 8 +loc 2e8f98f9891a8cba 1 +loc 2ec94c46492bdf4e 4 +loc 2ee347526cbe8582 30 +loc 2f0b87d442d670ab 11 +loc 2f379cdbd5b7baad 11 +loc 2f48393189526db5 4 +loc 2f750d28b00ff12a 3 +loc 2f8af7800010622b 7 +loc 306ff4fa969c2b5e 11 +loc 30823f7b3a383b0b 14 +loc 30b49386bfe531be 11 +loc 30b5d92358dcd253 11 +loc 30fdfead3bc13677 11 +loc 323f04fd5a7a671a 17 +loc 324c715b3ef59fb5 4 +loc 334ac2095aeeffb5 21 +loc 3440f8d589cae291 11 +loc 3455adbedae84514 11 +loc 345a2fdfacb84efa 13 +loc 34a9957d1f0f0b52 11 +loc 3529f53b490c717a 0 +loc 3599e84ad64ea3a0 11 +loc 35d9e976f25eecfb 6 +loc 3677e09eb4196cd4 11 +loc 36aff0a235db6e64 11 +loc 36eb5585be49ea31 2 +loc 374dc0d9439e660e 4 +loc 378070cac6c29d8a 3 +loc 37d1819c814b6e48 21 +loc 37e6c1a9572eed09 11 +loc 37f14128572d051e 3 +loc 37fde5021a0194e9 9 +loc 3817c48d6d009d40 5 +loc 3844be8089f457b2 11 +loc 386a103486cff027 17 +loc 388245783c0938fa 11 +loc 38b9413e0cbbaac0 5 +loc 38d2574e03270d77 12 +loc 39aa49c948c80691 13 +loc 39dc70474f1885ae 11 +loc 39e896bec2ab77c9 11 +loc 3a01c0eb5dcd7613 1 +loc 3ac893ff638f771b 11 +loc 3ad308b61b8f874e 6 +loc 3b670e3e6581709f 8 +loc 3b6d5a0ea9a511e9 6 +loc 3bb6aca50f06a606 11 +loc 3c3d72419cba9309 11 +loc 3ce01651a41aafb6 5 +loc 3d13afc5e29e371a 6 +loc 3d8282d048efcc77 3 +loc 3d903cc2ced5fe66 6 +loc 3e185994d574529f 10 +loc 3eede00189a7a15f 2 +loc 3f6dfe71df011044 13 +loc 3fd29f21cbad3628 11 +loc 3fd493d0cfa3199c 11 +loc 403c374008b3781f 11 +loc 4051d8f6c2af839f 2 +loc 410fa58f10ab2614 16 +loc 412954cd14f67658 24 +loc 412e3e27d4215e06 21 +loc 41673bb8d8375d55 2 +loc 418b8560d8501615 11 +loc 419553ea1dcb7cbb 0 +loc 41c0961bc8c2cab7 0 +loc 41d3ef4759cf2df9 14 +loc 41d5fcfae22584c4 21 +loc 420f8a970c0221f5 11 +loc 42b8290f3fed3450 8 +loc 42c814d98094dbb7 11 +loc 430cba9d0ec3ab56 8 +loc 437e4d914b709dbe 11 +loc 4425f307731f789f 3 +loc 447dd766bab76e3d 20 +loc 44c0fb3e0547cf06 14 +loc 44e4a85260ed66f3 9 +loc 4581af238c881954 19 +loc 45df799f4c412c1e 11 +loc 467a56bed6f21356 11 +loc 468e7a55aa64e481 9 +loc 468f6db3ee1d572b 16 +loc 46fbd074054bea23 11 +loc 470d78a9487c1c79 11 +loc 472324ee9732f077 2 +loc 473790676e54eb23 11 +loc 479d7c2ae4e70e80 10 +loc 47f97c0fb37c8568 4 +loc 47fa33217335c644 11 +loc 482cd3901a33f7da 4 +loc 4849325cb38239ae 19 +loc 48eb495d1a981e3b 11 +loc 492eb95e1d2c9566 11 +loc 49ce64a0c7a6ba56 1 +loc 4a5b1dd8e915b277 11 +loc 4ad0bfd06eca3b10 5 +loc 4afaa3ee804f31a3 2 +loc 4c031399d70c63d6 4 +loc 4c2c8773e6afbfc3 16 +loc 4c2caaf3d6ca8c78 11 +loc 4ca0617355962ae6 32 +loc 4ca9fa48f6567768 26 +loc 4cd5d6d5bc533efc 11 +loc 4d1f25b9f88ce40a 5 +loc 4dd0e56fa6b1cd26 11 +loc 4eb915efc09067e9 3 +loc 4f0a11cd12ebdd64 27 +loc 4f1eeefcc6361836 1 +loc 5016e2cec581b778 2 +loc 5098ceb6ba93f0f1 11 +loc 513a82879f6988a3 7 +loc 528f29df5a342dec 4 +loc 532d4763ee4f0d51 11 +loc 5349e49cedd311d3 11 +loc 539c00ef45a998b6 7 +loc 554719c9558ee90c 11 +loc 5567796a9df49bf1 8 +loc 5690a7e6f4b931fa 11 +loc 56d3eb4a00f6de55 3 +loc 56eccd261ee32406 4 +loc 57118e22b1dee7aa 26 +loc 5738d7b2ce7e1fb1 11 +loc 57aaa7dc54b71ee5 3 +loc 57cae2ed030dc221 9 +loc 5825699eb9713c9b 15 +loc 5844b083ab372a93 2 +loc 58b6201c2abb3c2d 23 +loc 58c2008305b0afe0 7 +loc 58ceb8247ac5a5e8 20 +loc 596a40ae7adf3b66 2 +loc 597bcfac2e1bfc60 0 +loc 59fafb2628d5e22c 7 +loc 5b7d514864a7fe74 11 +loc 5ba349d3f9dbe981 6 +loc 5ba413ec871b1a05 8 +loc 5bcbc43e0bfc1c04 11 +loc 5bef2751b97cdaf8 11 +loc 5c427223e4f7bd31 11 +loc 5c62bc4336a92380 43 +loc 5c976139ae63e37e 7 +loc 5cb29ea4b16ee5b4 14 +loc 5cff4150fa39e9ab 7 +loc 5d8dea77474eef46 8 +loc 5dc5ca231ce7af8c 9 +loc 5ddce9f50b0c275d 21 +loc 5e6955ad62fc35dd 22 +loc 5f4c2b1da49767c2 7 +loc 5faf45317652eb97 11 +loc 5fca1dab3eb84ff6 13 +loc 5fcec82716ef1539 11 +loc 60ae7b12db9b9c97 11 +loc 60fdd1aa679748b0 21 +loc 610775902254026b 2 +loc 61fe78aa31fda57a 42 +loc 62118ff9d5625689 11 +loc 62c4c7c808ddb517 15 +loc 63d5ec100a00a495 3 +loc 643e391062d5ad98 5 +loc 64cee7e94959c37e 11 +loc 65401e97a7b3dde3 25 +loc 6619eb15218ab6f4 17 +loc 662cc3aa77fb9b46 2 +loc 670c6a44b9bdada6 14 +loc 672566a3610ffd5f 11 +loc 67a27cf55608f473 11 +loc 681cdc3561eb7d64 4 +loc 689179ee148b90d7 11 +loc 68e893e8f3d71fe9 7 +loc 690e7d5a20a3fe48 1 +loc 696611d56fdfeda5 11 +loc 6a437d0c0e83cacb 2 +loc 6b5abc249db062e7 7 +loc 6ba0e82f39ba4f6a 14 +loc 6c08a8d51137d60d 11 +loc 6c0907eac176dd5f 35 +loc 6cbc12bcc60a4e8d 5 +loc 6ce6bd5b9c4a630f 2 +loc 6d661a0458d987d3 7 +loc 6d89a0375f525b9b 9 +loc 6e594189eafe0408 19 +loc 6e7d533058e61f70 1 +loc 6e8de06cc7d7c1b9 11 +loc 6e9f1d938c50aeaa 11 +loc 6ed1fd271b188d46 34 +loc 6f12044892549b6a 7 +loc 6f6b77ffc341ac63 11 +loc 6f92d0e9fea66324 2 +loc 701d98e1ee6bcead 7 +loc 703eae2147b6debd 16 +loc 709961d34ae00031 2 +loc 70acb27eb42344f9 7 +loc 70f36ffdb4452f92 7 +loc 718940229dd98516 0 +loc 71d2cb7f1d1a951f 7 +loc 72e0017a3e9b4ca1 11 +loc 72ef52b7bd594829 13 +loc 72f4fe268d6cb29a 22 +loc 735d655f4fb0fd03 11 +loc 737bc925aefcc6c5 19 +loc 7385fecc2a9f3960 8 +loc 73901c5b41eaae62 6 +loc 73e074eadb44c9f7 8 +loc 73e72ba081fc59e2 11 +loc 73f11932d9093d6f 13 +loc 7464bc75ffa4b7c6 13 +loc 74798dc894205762 4 +loc 752011234791fb28 11 +loc 755a08e70176b633 2 +loc 7594fbf007da4c74 6 +loc 75c8bef4f03819be 11 +loc 75eb9f6f072bdcb4 4 +loc 766d82bfd86dd118 2 +loc 76c1f41550b1b997 3 +loc 76dc0bb4769da62d 5 +loc 776b8b1eab8668f4 30 +loc 7791b74c1fceaf2a 36 +loc 77fbb3c024e536a6 11 +loc 7869708e768f836d 11 +loc 78af546e01ca37a8 18 +loc 795d777e64164e3c 15 +loc 79a9c21686684680 3 +loc 7a2991b69512efe4 5 +loc 7af16e2b108a072b 2 +loc 7b38b69469da3083 17 +loc 7bbbeff107c17bde 11 +loc 7c0c8d55f5c503d0 5 +loc 7cbebea3fdb2d0f2 3 +loc 7d74b2ed6c067b53 11 +loc 7e30b28a733bf314 2 +loc 7e6fd84a59cb93e2 2 +loc 7e865b52c71df10f 4 +loc 7f09d0dc03338350 11 +loc 7f0dfcb8e7044238 10 +loc 7f303a84eac3193d 20 +loc 7f4d61b6e5398157 20 +loc 80916eb377aa30c0 11 +loc 809f43336ad03a2a 11 +loc 80e0733dd1dd8967 2 +loc 8145a5f15c149225 1 +loc 81bdfa9c95a47411 11 +loc 81cb89a3777fa95b 16 +loc 823b34f6ccc08503 3 +loc 82789957ab0d60dd 11 +loc 8344f0e335b7777f 7 +loc 836124753f3d43ab 11 +loc 83617b448ae4f5b9 16 +loc 83bcd9fa7921e9d3 4 +loc 841f41fa7a532342 9 +loc 843711ddc2000a69 11 +loc 8444bfbbeb4b82aa 28 +loc 8448f217eadf0cb2 4 +loc 84f0a5079e308d96 4 +loc 85a1cf5cc0f384f6 50 +loc 85a803fc71028fe4 1 +loc 860c6bcc0adecb82 14 +loc 861e750aa9a0a707 7 +loc 86216d2ceafc4984 9 +loc 8634268abbb0b083 2 +loc 8642302fa250c337 6 +loc 86432dad9ce7d6cb 7 +loc 86a231892fd2185e 11 +loc 86a3f7bdf62de7a0 11 +loc 86bb4d97aa1153b2 4 +loc 86d9ce774ac0e9b2 2 +loc 873c2b8fb5cc9d7b 11 +loc 8751c283bf5503b0 26 +loc 882bbb80b4c33a2a 11 +loc 8834877c9b15df09 8 +loc 888095969ea9ab92 16 +loc 89776c50b7500989 5 +loc 89a3b60759c7a791 10 +loc 89f9967795227eb9 11 +loc 8a7b0fc3a462a5d6 19 +loc 8a917b2e79abf10b 11 +loc 8bfad7e73ac0394e 2 +loc 8ce48499c2224772 2 +loc 8d0b4c6c4e0b8287 2 +loc 8d5b6014948b0f22 11 +loc 8d74a3b2fe7eb966 3 +loc 8d9a330126fc06e2 21 +loc 8d9a862e3af62f77 11 +loc 8de5991c8f804dc9 13 +loc 8e8cf68def8d9e0e 9 +loc 8ed2c4409443dda9 11 +loc 8f9de2b50c6d0080 2 +loc 8feab80bf0370f32 5 +loc 8ff96a7fddf76be1 7 +loc 910bfa248b2227af 3 +loc 917b56d82c963db9 4 +loc 917beb804a058565 2 +loc 919157b8dd21ebc5 11 +loc 91dcc2e5054d5bab 3 +loc 925ba93869af880e 4 +loc 92938b5bcf3574b7 4 +loc 93323bfcca74ac92 9 +loc 939d426bd40f34bb 2 +loc 93b9d305982206ad 2 +loc 93bf0fd6213a7b84 0 +loc 9407348f6ee68f78 5 +loc 947bae210f6a7031 1 +loc 949ba1dba9a922ee 11 +loc 94aad1a9ad691252 0 +loc 954ed37fdd30474a 11 +loc 956291fcc7f26916 4 +loc 964723e6313d848e 6 +loc 966a40fe1711ccf0 2 +loc 96dfca7e24aaf25d 2 +loc 9777dc82c98426b8 4 +loc 977bf6fbb79350d3 51 +loc 979f53be1f68fa7b 10 +loc 97f4e74e9c4d232b 11 +loc 97fb9fe5299fca74 11 +loc 9854a9f84be0722e 4 +loc 989c038c6c9c954b 32 +loc 99054039062a6d56 11 +loc 9a08845a5a2b39d5 2 +loc 9aadf5d88db176de 5 +loc 9b00b22bd2dfcdea 2 +loc 9b15492f093e9680 3 +loc 9b675b24201a7db1 2 +loc 9c0e8638585adacf 6 +loc 9c2fdbb657785b23 27 +loc 9cf542d698798567 10 +loc 9d0250cf760a1cb1 6 +loc 9da180752e6d8e5c 14 +loc 9db9edf9dc3ccc47 10 +loc 9dfcef85a5056fc8 14 +loc 9e1070f26a7961f5 11 +loc 9ea88db302fadbad 2 +loc 9ef916a9ee01d457 11 +loc 9f4839d437dbdb32 1 +loc 9fe93961fce54ce4 11 +loc 9ff084684afeb19e 8 +loc a02c8cbee597d352 1 +loc a04d750c20c1a89a 8 +loc a077984a0c213988 1 +loc a0a05b5b3bae9287 11 +loc a0cb16a743107fec 3 +loc a0f0fea3368f2393 12 +loc a103963d54dbd311 11 +loc a17366facb51952d 2 +loc a188cfc59cab8daf 11 +loc a1b43eb494b1fcd0 12 +loc a2723c0c456ffc48 11 +loc a2750be13ac0ee82 1 +loc a2947b425b951a72 9 +loc a2c7a6bff645bd0b 3 +loc a2ea72ba4256d1ad 3 +loc a3135ee44b475bac 11 +loc a3c2bb108cce8ba0 35 +loc a3f0563f01c103d7 3 +loc a41d39c5795ca771 2 +loc a508a4b30b2f79a8 7 +loc a525edb704ff846a 7 +loc a5ae71540871bba8 7 +loc a604a99ead07e8b7 20 +loc a62599079371da08 9 +loc a649d84498c126fb 11 +loc a6575e6a24b93565 3 +loc a7d38a0353d337b6 4 +loc a9956363396fd78f 3 +loc aa7daecde0716211 16 +loc aa8df295f2993c33 2 +loc aafe6cbcf5a00afa 2 +loc ab7a128a7db81697 10 +loc ab8c28087d812326 30 +loc abb36acf86e05f44 4 +loc abfbbb2620e0ae26 4 +loc ac2dcd57d0e40382 10 +loc ac5f9599e49c6015 7 +loc ad36ead5dc5ed87e 12 +loc adb4262180fbbfa3 2 +loc aea683ac23a9c3c2 14 +loc aee6e643327d448c 4 +loc aee9bf52c500e4ed 4 +loc af029cbd84ef0056 5 +loc af2de214b51708e1 7 +loc b0176088079c92c2 14 +loc b02160df0862ef04 11 +loc b0a2e27b2dbbb205 16 +loc b0ccffd9ae0581de 11 +loc b13dd85e39366f9b 11 +loc b16c5ae4b8177443 2 +loc b1ca8c69bcb29f4c 11 +loc b25689d132f6a3aa 11 +loc b43807588b3d388c 56 +loc b550f7063b5e1658 3 +loc b55f7472956eb256 5 +loc b5ee57928f707a5b 11 +loc b5fb452f795be5ae 18 +loc b6774bf12793f446 4 +loc b70a9e2dff98d690 17 +loc b873090225df1db0 2 +loc b8c3995c4bdabe95 11 +loc b8e9148c718a83b2 5 +loc b926ca3a7fe53ba2 8 +loc b92a296420f44bb4 3 +loc b999d958d4795300 6 +loc b99d7ec7f2aaacc4 4 +loc ba063e007e4a0089 3 +loc ba0efed99d11207b 19 +loc bac2ec39302668c7 21 +loc bad8f1522ba80ebe 25 +loc bafd9e14f2744298 11 +loc bb14250915d7445f 28 +loc bb4bef0b64fd8d48 4 +loc bb70288b5c1a5ab3 6 +loc bb774894bae68dfb 4 +loc bb7a2eb4b3663ef8 8 +loc bba7ed99af8f5b54 26 +loc bbd0138ed7a0d350 23 +loc bbee458ed08455f3 11 +loc bc12021c4f16c7b1 11 +loc bc2a33a973f536c4 11 +loc bcb1ccbb00ea585f 11 +loc bcdea3156f45a122 5 +loc bd028205e65b668b 4 +loc bd1086a51723f0a9 11 +loc bd409f1d90d1ec7e 1 +loc bd99db1ff250e606 5 +loc bda35bf5d19afe35 2 +loc bde9492df89e79bb 26 +loc bdfa2256a1243766 11 +loc be1c296b4103541a 2 +loc be5051b47f524a38 1 +loc be608bd98b316613 1 +loc be6f1003b9147c1e 1 +loc be9f5fbfbcf49f3b 2 +loc beab83a2a041144e 11 +loc bfc39e13eac109b5 11 +loc bfe88aff0d1cf1ac 11 +loc c04451c38eea2f3d 2 +loc c0f637cf0b098e2e 31 +loc c12ea4bb6c4307ad 11 +loc c141ab01fc2a3cd6 12 +loc c1b86e1466d682c2 5 +loc c1d80e01a94fb0d5 2 +loc c2b7d06e06237be2 7 +loc c372db7ad947ef02 34 +loc c37c3f7fe77cffe5 10 +loc c5ae5fa47a4cf64f 11 +loc c5b0ee41b05acfb2 4 +loc c60ec2d90df2762b 8 +loc c6a6785521523225 26 +loc c77b05daa4ff56ae 9 +loc c7a2188f77229408 11 +loc c83cc8417e632ce2 11 +loc c853f415c89a1d6a 11 +loc c85f0d56aa6253b5 4 +loc c8c1c712735b0ccc 11 +loc c90f6570abc63716 2 +loc c9417f90820411a2 11 +loc c972eb6e158540ea 11 +loc c99075537de59d30 7 +loc ca51003e7d5cf837 1 +loc ca612ead4b39d244 5 +loc cb0370a917abe0df 10 +loc cb488f245b2795da 10 +loc cba3c34ae22ec49b 11 +loc cbaf49d9aa662ec8 11 +loc cc256dd80b037384 2 +loc cc329731922ce601 2 +loc cc383f93cc4a1177 11 +loc cc527f122cca2eee 3 +loc cc71575aafbcac28 3 +loc cd44a949d2c08a9d 2 +loc cd514d3bf7b25bba 17 +loc cdbea33e56e46c5a 11 +loc ce20725a9f915d01 11 +loc ce275778f4d4977d 6 +loc ce288f34f8e6e4af 2 +loc cf024f76b08afd38 38 +loc cfdc0c8bc7aaaf12 10 +loc d05a659360ba6f3c 7 +loc d0dc0130bbaffff0 11 +loc d1d5db6a15028a9b 2 +loc d2085fbebccdffda 11 +loc d25a3ce67a83b39b 2 +loc d27009c85a85fc05 7 +loc d2b8b85442a71925 11 +loc d2d13b315fa7cda4 3 +loc d3c8d6b28ec1e96c 1 +loc d3f0565c6040da91 11 +loc d3fcd81e343f10ef 20 +loc d4885e4f3e20b47b 7 +loc d491b0153cfde2d8 5 +loc d4d8059fa01f1e3c 11 +loc d4dce2d2b3a5855d 2 +loc d4e70b54741103c0 11 +loc d50d180e3f8249f7 1 +loc d51295ab04b0d39c 0 +loc d560adb4a23e2cff 11 +loc d5b13308dc43b558 4 +loc d5bb33cd7f8fba6b 12 +loc d65071a443bfd4aa 10 +loc d72258d6542408c2 11 +loc d78a9a1b73a37bc2 9 +loc d7bdfd13e38c53eb 4 +loc d83135f37d441e8a 6 +loc d85fa2a1d24a1d9f 2 +loc d864f94da21ec08c 11 +loc d8bec32aecbb5851 10 +loc d8cb3bc23bf7e3c7 4 +loc d8e1d53e3c403ae2 26 +loc d900a7d57a7a6e51 2 +loc d94841dd9e08985b 3 +loc d9a72ef3652ab4bd 11 +loc d9b34ca129cedb0a 11 +loc da2f7f459c5ddf7f 27 +loc da42d957244e0f81 2 +loc da62006c9ee9330e 1 +loc da950ddf8dfe0436 11 +loc dafd603c2f091213 12 +loc db6e9f0bb941444e 4 +loc db7e32c39b011150 0 +loc dbee05807e339e8d 12 +loc dbf55aba3ae608cc 11 +loc dc57c1548a19f6ef 2 +loc dcd3b9c5600c74ac 4 +loc dce93392eeee89dd 11 +loc dd55181effa0995b 2 +loc dd7f4ba2505ff33c 26 +loc ddf05a9fda55b0c4 0 +loc de0c720a86e29e55 11 +loc de35fac2281b4eea 1 +loc de6229cf181cd846 11 +loc de6f49bdb8c48e7b 11 +loc de9df27d11fd6d43 2 +loc df0e0f92f2092438 6 +loc df22835a99ccf5bc 12 +loc e0d588512343fa21 11 +loc e10010a21b629e66 2 +loc e146ee1a4a342aac 1 +loc e1b4c1103fc2ed5b 11 +loc e23c2226eb4158af 11 +loc e2699fd6a3fa1328 11 +loc e2fe3265394778ce 23 +loc e300b1faf6e1d012 13 +loc e36cf8c9d9f1cd89 10 +loc e378ef5afbb3e408 24 +loc e3a90ae9514813f6 1 +loc e3ad8a7c6e96972c 2 +loc e3d2d3a21b154af6 11 +loc e40b81d45cf6363d 10 +loc e45f0d4802d2848a 4 +loc e46ab7a2d342d4e1 5 +loc e475358369fb36cd 11 +loc e49178f48c0574fa 12 +loc e4c073933f1e47b6 10 +loc e51a8d8ddd82a50d 2 +loc e53f1181d710f13f 11 +loc e57a61300c415efd 10 +loc e5eab62fa164f5df 9 +loc e6378805498ae207 18 +loc e65813db8d9b2bc6 3 +loc e672f75e6728a41b 11 +loc e6e4049fbb37155b 1 +loc e7afc20b0204158a 7 +loc e81adba88c94d542 6 +loc e81be21ddfb4b2ca 11 +loc e83f724256ae1c03 7 +loc e8bf877e84fd93c8 8 +loc e963d0798b6c2416 20 +loc e98c2bc92448636c 12 +loc ea67c8fe6f523c94 9 +loc eaaca8031d102a8a 11 +loc eabdb1c11c0b7344 13 +loc eaf3758011d7fd20 1 +loc eb21547e8fc5798d 2 +loc eb59730bea76712d 11 +loc ebd54b9559a8d1dc 11 +loc ebe6c7d83a7b0b88 11 +loc ec0a3b87d49de600 6 +loc ec23757266779a3d 7 +loc ec238b174e9bc2c2 5 +loc ec571d26837759c3 11 +loc ed0f421581be922c 4 +loc ed51829342734599 16 +loc ed5af324fbb452ce 12 +loc edc3a81a5285678e 12 +loc ee8b47838e145fcc 16 +loc ee9fc2ad102cff33 7 +loc ef0b1c5fa0b90ab9 11 +loc ef1d53b8c44bbb67 11 +loc ef29cc93418187ad 15 +loc efb4a906769bc50a 19 +loc f028f44bfc9220df 0 +loc f03f21e4f1638e0f 11 +loc f103a8f1cc586bbe 12 +loc f148d5a58bdea0ef 11 +loc f189dcfe4fda591e 11 +loc f197817f969bf7c7 11 +loc f1e3e5d2694a3263 11 +loc f2b1cab4ba10688d 2 +loc f2e357d4e331608a 3 +loc f3522319ec73e6b5 3 +loc f375a8b30995f676 35 +loc f38296f6730d0380 62 +loc f38d7d97db1cc3a5 11 +loc f4af891e3663df17 11 +loc f5da182a28999848 11 +loc f6499fa9b56dd88d 2 +loc f64f3b1838d23608 11 +loc f69b15b433a0ebb1 6 +loc f6cd3e68decce213 5 +loc f6d09aaa879795f5 2 +loc f740404a18ee9317 10 +loc f83dbf2f26b3d9d6 9 +loc f85e3c71bf15f382 11 +loc f8ff1b999d5139e6 6 +loc f90db1a16f62ee03 2 +loc f953ae6dc1284559 2 +loc f9710e2cab88cd14 11 +loc f9bbc08faef1cd9a 3 +loc f9f84d21d21eb570 11 +loc fa47b525839d3e9b 10 +loc fa6e14ca0bb22b76 5 +loc fb57d0b51a01ea3d 5 +loc fc41f933bfe74d4c 11 +loc fc7f155243219c03 11 +loc fd19ef11039692b7 9 +loc fd619dcdcef1e43e 1 +loc fde1a69149bde1e8 11 +loc fe09c2eea1d66805 18 +loc fe21950435627c9a 16 +loc fe7c1a71bbacd6c7 11 +loc fedbe7b3fffd467c 11 +loc fef21dd5727bbb51 12 +loc ffd1fc17b731e639 16 +loc ffec8b3317b77318 7 +nest 4b733c4d83b07 0 +nest 23f7b60bac46e6 0 +nest 3db89571c08346 0 +nest 1b18ff4bbe15dc8 0 +nest 2340a4830b3aea6 0 +nest 24f99e084d8eac7 0 +nest 25992276d82d57d 0 +nest 28c515cc9cf15ea 0 +nest 292e78225cb9e42 0 +nest 33b850c282de191 1 +nest 3d91ed95503b07c 0 +nest 404b9670f3a703b 0 +nest 43c9282165db6d5 0 +nest 53a30a545ebbaa8 0 +nest 567a6c2fb1b14e3 0 +nest 60be78b537264df 0 +nest 6434baca4e727a3 0 +nest 6593a5202605144 0 +nest 6fa75d99a22b8f8 0 +nest 75ca46ce0ff4da0 0 +nest 7b978a659fba30c 0 +nest 7cab3f5048545bb 0 +nest 7ecd582e890ea31 0 +nest 827933c0d01af9f 0 +nest 8528c5691f65b73 0 +nest 85e190a60167712 1 +nest 8819a45d3577c0d 0 +nest 8cfc26a65c57400 0 +nest 90af57aa911e8c8 0 +nest 921b5208a7d7443 0 +nest 9290bff224c908f 0 +nest 96de467da885077 0 +nest 9ca30156b74ccee 0 +nest 9d3f23b15ca9b60 0 +nest a366a6cfe2d6884 0 +nest b42253871667dfe 0 +nest b50f1f49cd3855e 0 +nest b516b97cb14b102 2 +nest babbb34b92b7a3c 1 +nest baff7355e29d862 1 +nest c17f5e8b96c68e7 0 +nest c7aa4beae78784c 1 +nest cc857a81514720b 0 +nest ce934e313754b1f 1 +nest d2cc7984bc1c193 2 +nest d2f8bf6bd734dbe 0 +nest db814bc584f0227 0 +nest dfda1f29948b476 0 +nest e287345a1da751d 0 +nest e76fa62254196bb 0 +nest 10ab7d01d10ccbe2 0 +nest 10d0776f5dc84500 0 +nest 1102ee13c3f287bc 0 +nest 119e3a9de1579038 0 +nest 11b9cd4c327155e8 0 +nest 11cdabaaa36de566 1 +nest 11e1842b0846e3c2 1 +nest 121769c14e306576 0 +nest 123ef12c5471047c 0 +nest 12d781289b1f6537 1 +nest 130120439ca7e74e 0 +nest 13e98b0aac2ac45a 0 +nest 1439d5906edb93a5 0 +nest 153e1fedb7354bc2 0 +nest 1544b3bf28bbba4d 0 +nest 15cd8d11c115e37d 0 +nest 16b12a8a9877f141 0 +nest 1706f0f1d4b7d5aa 0 +nest 1784741bfa7af1fd 0 +nest 190843bf16d81bd4 1 +nest 1a3d08994f90d6f9 0 +nest 1a8c427d3dec251d 0 +nest 1a99603a2e88d654 0 +nest 1aefd4b805333268 0 +nest 1c1f924943890c8e 0 +nest 1c470b2e35f931bf 0 +nest 1c6db503caf6a152 0 +nest 1cf5097d9b16f9b0 0 +nest 1d00916cd2964c35 1 +nest 1d08599882d7cfe7 0 +nest 1d1c8eb3ae5168fe 0 +nest 1d2fa22ed4ee9ff2 0 +nest 1d5a11c7d1edeb11 0 +nest 1db269061a925799 0 +nest 1e539f3e54012138 0 +nest 1e6ca46d5635c401 0 +nest 1e8a64d9423479a1 0 +nest 1ec2fb826c707c29 0 +nest 1f2b736eb17cf738 0 +nest 1fc0fedd54c8e75b 0 +nest 20305a32e94c6e20 0 +nest 209b66ed45486485 2 +nest 215d259a676f1c6b 0 +nest 21b214aba3517cf9 0 +nest 21dbd83072a7a2b0 0 +nest 220a016c123269d2 0 +nest 22f146b9d55161f5 0 +nest 233ed227bd9ad4ec 1 +nest 23a4ccb986e9c241 0 +nest 23b429d4f8393438 0 +nest 242bbb0e5b9ec108 0 +nest 242d896b0833bcf6 0 +nest 2449a169abfc5709 1 +nest 248d8aea5119d112 0 +nest 24d563a0bd0284ee 0 +nest 24d61732a772f6ce 0 +nest 258ed6b3635d219f 0 +nest 25b33c65ec17c605 0 +nest 265eeb107f4f7037 0 +nest 26600eca83f91cdc 1 +nest 2694ccc9cd9c2e4a 0 +nest 26f2906b4561e5ed 0 +nest 270e54f53f0dec25 0 +nest 2765d6c249664ab6 0 +nest 27af60e478606323 0 +nest 27f05b6efba965c8 0 +nest 27fcbd42c188ec3e 0 +nest 28a6e8c239e04047 0 +nest 2909b7f6c9f5d18d 0 +nest 29167175f22af761 0 +nest 297a4160638db8fd 0 +nest 29b64a559ea4ffff 0 +nest 29d1bc2a36afc76d 0 +nest 29f5a11bbf71babc 0 +nest 2a14568b12903218 0 +nest 2a69b5e495fafcb6 0 +nest 2a9e43e7514907ba 0 +nest 2ae26defd322202f 0 +nest 2afac393e3eb18c6 0 +nest 2b3b72e3b3627a3a 1 +nest 2b7f42fc1c74594d 0 +nest 2b81eacbca431848 0 +nest 2c77117a0e84cfe1 0 +nest 2d4f0807a4377e66 0 +nest 2d6194ec9f73238e 0 +nest 2d8466f6954e126e 0 +nest 2d96edc3799baa0e 0 +nest 2dd16cc3aa6f6523 0 +nest 2e0b841baad1468e 0 +nest 2e395ab5adfaffe4 0 +nest 2e8b4e73d01cda1d 0 +nest 2e8f98f9891a8cba 0 +nest 2ec94c46492bdf4e 0 +nest 2ee347526cbe8582 1 +nest 2f0b87d442d670ab 0 +nest 2f379cdbd5b7baad 0 +nest 2f48393189526db5 0 +nest 2f750d28b00ff12a 0 +nest 2f8af7800010622b 1 +nest 306ff4fa969c2b5e 0 +nest 30823f7b3a383b0b 1 +nest 30b49386bfe531be 0 +nest 30b5d92358dcd253 0 +nest 30fdfead3bc13677 0 +nest 323f04fd5a7a671a 0 +nest 324c715b3ef59fb5 0 +nest 334ac2095aeeffb5 0 +nest 3440f8d589cae291 0 +nest 3455adbedae84514 0 +nest 345a2fdfacb84efa 1 +nest 34a9957d1f0f0b52 0 +nest 3529f53b490c717a 0 +nest 3599e84ad64ea3a0 0 +nest 35d9e976f25eecfb 0 +nest 3677e09eb4196cd4 0 +nest 36aff0a235db6e64 0 +nest 36eb5585be49ea31 0 +nest 374dc0d9439e660e 0 +nest 378070cac6c29d8a 0 +nest 37d1819c814b6e48 0 +nest 37e6c1a9572eed09 0 +nest 37f14128572d051e 0 +nest 37fde5021a0194e9 1 +nest 3817c48d6d009d40 0 +nest 3844be8089f457b2 0 +nest 386a103486cff027 0 +nest 388245783c0938fa 0 +nest 38b9413e0cbbaac0 0 +nest 38d2574e03270d77 0 +nest 39aa49c948c80691 0 +nest 39dc70474f1885ae 0 +nest 39e896bec2ab77c9 0 +nest 3a01c0eb5dcd7613 0 +nest 3ac893ff638f771b 0 +nest 3ad308b61b8f874e 1 +nest 3b670e3e6581709f 0 +nest 3b6d5a0ea9a511e9 0 +nest 3bb6aca50f06a606 0 +nest 3c3d72419cba9309 0 +nest 3ce01651a41aafb6 0 +nest 3d13afc5e29e371a 0 +nest 3d8282d048efcc77 0 +nest 3d903cc2ced5fe66 0 +nest 3e185994d574529f 0 +nest 3eede00189a7a15f 0 +nest 3f6dfe71df011044 1 +nest 3fd29f21cbad3628 0 +nest 3fd493d0cfa3199c 0 +nest 403c374008b3781f 0 +nest 4051d8f6c2af839f 0 +nest 410fa58f10ab2614 0 +nest 412954cd14f67658 0 +nest 412e3e27d4215e06 1 +nest 41673bb8d8375d55 0 +nest 418b8560d8501615 0 +nest 419553ea1dcb7cbb 0 +nest 41c0961bc8c2cab7 0 +nest 41d3ef4759cf2df9 0 +nest 41d5fcfae22584c4 0 +nest 420f8a970c0221f5 0 +nest 42b8290f3fed3450 0 +nest 42c814d98094dbb7 0 +nest 430cba9d0ec3ab56 0 +nest 437e4d914b709dbe 0 +nest 4425f307731f789f 0 +nest 447dd766bab76e3d 0 +nest 44c0fb3e0547cf06 0 +nest 44e4a85260ed66f3 1 +nest 4581af238c881954 1 +nest 45df799f4c412c1e 0 +nest 467a56bed6f21356 0 +nest 468e7a55aa64e481 0 +nest 468f6db3ee1d572b 0 +nest 46fbd074054bea23 0 +nest 470d78a9487c1c79 0 +nest 472324ee9732f077 0 +nest 473790676e54eb23 0 +nest 479d7c2ae4e70e80 1 +nest 47f97c0fb37c8568 0 +nest 47fa33217335c644 0 +nest 482cd3901a33f7da 0 +nest 4849325cb38239ae 0 +nest 48eb495d1a981e3b 0 +nest 492eb95e1d2c9566 0 +nest 49ce64a0c7a6ba56 0 +nest 4a5b1dd8e915b277 0 +nest 4ad0bfd06eca3b10 0 +nest 4afaa3ee804f31a3 0 +nest 4c031399d70c63d6 0 +nest 4c2c8773e6afbfc3 2 +nest 4c2caaf3d6ca8c78 0 +nest 4ca0617355962ae6 0 +nest 4ca9fa48f6567768 0 +nest 4cd5d6d5bc533efc 0 +nest 4d1f25b9f88ce40a 0 +nest 4dd0e56fa6b1cd26 0 +nest 4eb915efc09067e9 0 +nest 4f0a11cd12ebdd64 0 +nest 4f1eeefcc6361836 0 +nest 5016e2cec581b778 0 +nest 5098ceb6ba93f0f1 0 +nest 513a82879f6988a3 0 +nest 528f29df5a342dec 0 +nest 532d4763ee4f0d51 0 +nest 5349e49cedd311d3 0 +nest 539c00ef45a998b6 0 +nest 554719c9558ee90c 0 +nest 5567796a9df49bf1 1 +nest 5690a7e6f4b931fa 0 +nest 56d3eb4a00f6de55 0 +nest 56eccd261ee32406 0 +nest 57118e22b1dee7aa 1 +nest 5738d7b2ce7e1fb1 0 +nest 57aaa7dc54b71ee5 0 +nest 57cae2ed030dc221 1 +nest 5825699eb9713c9b 0 +nest 5844b083ab372a93 0 +nest 58b6201c2abb3c2d 0 +nest 58c2008305b0afe0 1 +nest 58ceb8247ac5a5e8 0 +nest 596a40ae7adf3b66 0 +nest 597bcfac2e1bfc60 0 +nest 59fafb2628d5e22c 0 +nest 5b7d514864a7fe74 0 +nest 5ba349d3f9dbe981 0 +nest 5ba413ec871b1a05 0 +nest 5bcbc43e0bfc1c04 0 +nest 5bef2751b97cdaf8 0 +nest 5c427223e4f7bd31 0 +nest 5c62bc4336a92380 0 +nest 5c976139ae63e37e 1 +nest 5cb29ea4b16ee5b4 0 +nest 5cff4150fa39e9ab 1 +nest 5d8dea77474eef46 1 +nest 5dc5ca231ce7af8c 0 +nest 5ddce9f50b0c275d 0 +nest 5e6955ad62fc35dd 1 +nest 5f4c2b1da49767c2 2 +nest 5faf45317652eb97 0 +nest 5fca1dab3eb84ff6 0 +nest 5fcec82716ef1539 0 +nest 60ae7b12db9b9c97 0 +nest 60fdd1aa679748b0 0 +nest 610775902254026b 0 +nest 61fe78aa31fda57a 2 +nest 62118ff9d5625689 0 +nest 62c4c7c808ddb517 0 +nest 63d5ec100a00a495 0 +nest 643e391062d5ad98 0 +nest 64cee7e94959c37e 0 +nest 65401e97a7b3dde3 3 +nest 6619eb15218ab6f4 0 +nest 662cc3aa77fb9b46 0 +nest 670c6a44b9bdada6 1 +nest 672566a3610ffd5f 0 +nest 67a27cf55608f473 0 +nest 681cdc3561eb7d64 0 +nest 689179ee148b90d7 0 +nest 68e893e8f3d71fe9 0 +nest 690e7d5a20a3fe48 0 +nest 696611d56fdfeda5 0 +nest 6a437d0c0e83cacb 0 +nest 6b5abc249db062e7 1 +nest 6ba0e82f39ba4f6a 1 +nest 6c08a8d51137d60d 0 +nest 6c0907eac176dd5f 1 +nest 6cbc12bcc60a4e8d 0 +nest 6ce6bd5b9c4a630f 0 +nest 6d661a0458d987d3 0 +nest 6d89a0375f525b9b 1 +nest 6e594189eafe0408 1 +nest 6e7d533058e61f70 0 +nest 6e8de06cc7d7c1b9 0 +nest 6e9f1d938c50aeaa 0 +nest 6ed1fd271b188d46 0 +nest 6f12044892549b6a 0 +nest 6f6b77ffc341ac63 0 +nest 6f92d0e9fea66324 0 +nest 701d98e1ee6bcead 0 +nest 703eae2147b6debd 1 +nest 709961d34ae00031 0 +nest 70acb27eb42344f9 0 +nest 70f36ffdb4452f92 1 +nest 718940229dd98516 0 +nest 71d2cb7f1d1a951f 1 +nest 72e0017a3e9b4ca1 0 +nest 72ef52b7bd594829 1 +nest 72f4fe268d6cb29a 0 +nest 735d655f4fb0fd03 0 +nest 737bc925aefcc6c5 0 +nest 7385fecc2a9f3960 0 +nest 73901c5b41eaae62 0 +nest 73e074eadb44c9f7 0 +nest 73e72ba081fc59e2 0 +nest 73f11932d9093d6f 0 +nest 7464bc75ffa4b7c6 0 +nest 74798dc894205762 0 +nest 752011234791fb28 0 +nest 755a08e70176b633 0 +nest 7594fbf007da4c74 0 +nest 75c8bef4f03819be 0 +nest 75eb9f6f072bdcb4 0 +nest 766d82bfd86dd118 0 +nest 76c1f41550b1b997 1 +nest 76dc0bb4769da62d 0 +nest 776b8b1eab8668f4 0 +nest 7791b74c1fceaf2a 3 +nest 77fbb3c024e536a6 0 +nest 7869708e768f836d 0 +nest 78af546e01ca37a8 0 +nest 795d777e64164e3c 1 +nest 79a9c21686684680 0 +nest 7a2991b69512efe4 0 +nest 7af16e2b108a072b 0 +nest 7b38b69469da3083 0 +nest 7bbbeff107c17bde 0 +nest 7c0c8d55f5c503d0 0 +nest 7cbebea3fdb2d0f2 0 +nest 7d74b2ed6c067b53 0 +nest 7e30b28a733bf314 0 +nest 7e6fd84a59cb93e2 0 +nest 7e865b52c71df10f 0 +nest 7f09d0dc03338350 0 +nest 7f0dfcb8e7044238 0 +nest 7f303a84eac3193d 1 +nest 7f4d61b6e5398157 0 +nest 80916eb377aa30c0 0 +nest 809f43336ad03a2a 1 +nest 80e0733dd1dd8967 0 +nest 8145a5f15c149225 0 +nest 81bdfa9c95a47411 1 +nest 81cb89a3777fa95b 0 +nest 823b34f6ccc08503 0 +nest 82789957ab0d60dd 0 +nest 8344f0e335b7777f 0 +nest 836124753f3d43ab 0 +nest 83617b448ae4f5b9 0 +nest 83bcd9fa7921e9d3 0 +nest 841f41fa7a532342 1 +nest 843711ddc2000a69 0 +nest 8444bfbbeb4b82aa 0 +nest 8448f217eadf0cb2 0 +nest 84f0a5079e308d96 0 +nest 85a1cf5cc0f384f6 0 +nest 85a803fc71028fe4 0 +nest 860c6bcc0adecb82 0 +nest 861e750aa9a0a707 0 +nest 86216d2ceafc4984 0 +nest 8634268abbb0b083 0 +nest 8642302fa250c337 0 +nest 86432dad9ce7d6cb 1 +nest 86a231892fd2185e 0 +nest 86a3f7bdf62de7a0 0 +nest 86bb4d97aa1153b2 0 +nest 86d9ce774ac0e9b2 0 +nest 873c2b8fb5cc9d7b 0 +nest 8751c283bf5503b0 4 +nest 882bbb80b4c33a2a 0 +nest 8834877c9b15df09 0 +nest 888095969ea9ab92 0 +nest 89776c50b7500989 0 +nest 89a3b60759c7a791 0 +nest 89f9967795227eb9 0 +nest 8a7b0fc3a462a5d6 0 +nest 8a917b2e79abf10b 0 +nest 8bfad7e73ac0394e 0 +nest 8ce48499c2224772 0 +nest 8d0b4c6c4e0b8287 0 +nest 8d5b6014948b0f22 0 +nest 8d74a3b2fe7eb966 0 +nest 8d9a330126fc06e2 0 +nest 8d9a862e3af62f77 0 +nest 8de5991c8f804dc9 0 +nest 8e8cf68def8d9e0e 0 +nest 8ed2c4409443dda9 0 +nest 8f9de2b50c6d0080 0 +nest 8feab80bf0370f32 0 +nest 8ff96a7fddf76be1 0 +nest 910bfa248b2227af 0 +nest 917b56d82c963db9 0 +nest 917beb804a058565 0 +nest 919157b8dd21ebc5 0 +nest 91dcc2e5054d5bab 0 +nest 925ba93869af880e 0 +nest 92938b5bcf3574b7 0 +nest 93323bfcca74ac92 0 +nest 939d426bd40f34bb 0 +nest 93b9d305982206ad 0 +nest 93bf0fd6213a7b84 0 +nest 9407348f6ee68f78 0 +nest 947bae210f6a7031 0 +nest 949ba1dba9a922ee 0 +nest 94aad1a9ad691252 0 +nest 954ed37fdd30474a 0 +nest 956291fcc7f26916 0 +nest 964723e6313d848e 0 +nest 966a40fe1711ccf0 0 +nest 96dfca7e24aaf25d 0 +nest 9777dc82c98426b8 0 +nest 977bf6fbb79350d3 0 +nest 979f53be1f68fa7b 0 +nest 97f4e74e9c4d232b 0 +nest 97fb9fe5299fca74 0 +nest 9854a9f84be0722e 0 +nest 989c038c6c9c954b 2 +nest 99054039062a6d56 0 +nest 9a08845a5a2b39d5 0 +nest 9aadf5d88db176de 0 +nest 9b00b22bd2dfcdea 0 +nest 9b15492f093e9680 0 +nest 9b675b24201a7db1 0 +nest 9c0e8638585adacf 0 +nest 9c2fdbb657785b23 0 +nest 9cf542d698798567 0 +nest 9d0250cf760a1cb1 0 +nest 9da180752e6d8e5c 0 +nest 9db9edf9dc3ccc47 0 +nest 9dfcef85a5056fc8 0 +nest 9e1070f26a7961f5 0 +nest 9ea88db302fadbad 0 +nest 9ef916a9ee01d457 0 +nest 9f4839d437dbdb32 0 +nest 9fe93961fce54ce4 0 +nest 9ff084684afeb19e 0 +nest a02c8cbee597d352 0 +nest a04d750c20c1a89a 0 +nest a077984a0c213988 0 +nest a0a05b5b3bae9287 0 +nest a0cb16a743107fec 0 +nest a0f0fea3368f2393 0 +nest a103963d54dbd311 0 +nest a17366facb51952d 0 +nest a188cfc59cab8daf 0 +nest a1b43eb494b1fcd0 0 +nest a2723c0c456ffc48 0 +nest a2750be13ac0ee82 0 +nest a2947b425b951a72 0 +nest a2c7a6bff645bd0b 0 +nest a2ea72ba4256d1ad 0 +nest a3135ee44b475bac 0 +nest a3c2bb108cce8ba0 1 +nest a3f0563f01c103d7 0 +nest a41d39c5795ca771 0 +nest a508a4b30b2f79a8 1 +nest a525edb704ff846a 1 +nest a5ae71540871bba8 0 +nest a604a99ead07e8b7 1 +nest a62599079371da08 0 +nest a649d84498c126fb 0 +nest a6575e6a24b93565 0 +nest a7d38a0353d337b6 0 +nest a9956363396fd78f 0 +nest aa7daecde0716211 0 +nest aa8df295f2993c33 0 +nest aafe6cbcf5a00afa 0 +nest ab7a128a7db81697 0 +nest ab8c28087d812326 0 +nest abb36acf86e05f44 0 +nest abfbbb2620e0ae26 0 +nest ac2dcd57d0e40382 1 +nest ac5f9599e49c6015 0 +nest ad36ead5dc5ed87e 0 +nest adb4262180fbbfa3 0 +nest aea683ac23a9c3c2 0 +nest aee6e643327d448c 0 +nest aee9bf52c500e4ed 0 +nest af029cbd84ef0056 0 +nest af2de214b51708e1 1 +nest b0176088079c92c2 1 +nest b02160df0862ef04 0 +nest b0a2e27b2dbbb205 0 +nest b0ccffd9ae0581de 0 +nest b13dd85e39366f9b 0 +nest b16c5ae4b8177443 0 +nest b1ca8c69bcb29f4c 0 +nest b25689d132f6a3aa 0 +nest b43807588b3d388c 2 +nest b550f7063b5e1658 0 +nest b55f7472956eb256 0 +nest b5ee57928f707a5b 0 +nest b5fb452f795be5ae 0 +nest b6774bf12793f446 0 +nest b70a9e2dff98d690 0 +nest b873090225df1db0 0 +nest b8c3995c4bdabe95 0 +nest b8e9148c718a83b2 0 +nest b926ca3a7fe53ba2 0 +nest b92a296420f44bb4 0 +nest b999d958d4795300 0 +nest b99d7ec7f2aaacc4 0 +nest ba063e007e4a0089 0 +nest ba0efed99d11207b 0 +nest bac2ec39302668c7 1 +nest bad8f1522ba80ebe 0 +nest bafd9e14f2744298 0 +nest bb14250915d7445f 0 +nest bb4bef0b64fd8d48 0 +nest bb70288b5c1a5ab3 0 +nest bb774894bae68dfb 0 +nest bb7a2eb4b3663ef8 0 +nest bba7ed99af8f5b54 0 +nest bbd0138ed7a0d350 0 +nest bbee458ed08455f3 0 +nest bc12021c4f16c7b1 0 +nest bc2a33a973f536c4 0 +nest bcb1ccbb00ea585f 0 +nest bcdea3156f45a122 0 +nest bd028205e65b668b 0 +nest bd1086a51723f0a9 0 +nest bd409f1d90d1ec7e 0 +nest bd99db1ff250e606 0 +nest bda35bf5d19afe35 0 +nest bde9492df89e79bb 0 +nest bdfa2256a1243766 0 +nest be1c296b4103541a 0 +nest be5051b47f524a38 0 +nest be608bd98b316613 0 +nest be6f1003b9147c1e 0 +nest be9f5fbfbcf49f3b 0 +nest beab83a2a041144e 0 +nest bfc39e13eac109b5 0 +nest bfe88aff0d1cf1ac 0 +nest c04451c38eea2f3d 0 +nest c0f637cf0b098e2e 3 +nest c12ea4bb6c4307ad 0 +nest c141ab01fc2a3cd6 0 +nest c1b86e1466d682c2 0 +nest c1d80e01a94fb0d5 0 +nest c2b7d06e06237be2 0 +nest c372db7ad947ef02 2 +nest c37c3f7fe77cffe5 0 +nest c5ae5fa47a4cf64f 0 +nest c5b0ee41b05acfb2 0 +nest c60ec2d90df2762b 0 +nest c6a6785521523225 1 +nest c77b05daa4ff56ae 1 +nest c7a2188f77229408 0 +nest c83cc8417e632ce2 0 +nest c853f415c89a1d6a 0 +nest c85f0d56aa6253b5 0 +nest c8c1c712735b0ccc 0 +nest c90f6570abc63716 0 +nest c9417f90820411a2 0 +nest c972eb6e158540ea 0 +nest c99075537de59d30 1 +nest ca51003e7d5cf837 0 +nest ca612ead4b39d244 0 +nest cb0370a917abe0df 0 +nest cb488f245b2795da 0 +nest cba3c34ae22ec49b 0 +nest cbaf49d9aa662ec8 0 +nest cc256dd80b037384 0 +nest cc329731922ce601 0 +nest cc383f93cc4a1177 0 +nest cc527f122cca2eee 0 +nest cc71575aafbcac28 0 +nest cd44a949d2c08a9d 0 +nest cd514d3bf7b25bba 2 +nest cdbea33e56e46c5a 0 +nest ce20725a9f915d01 0 +nest ce275778f4d4977d 0 +nest ce288f34f8e6e4af 0 +nest cf024f76b08afd38 1 +nest cfdc0c8bc7aaaf12 0 +nest d05a659360ba6f3c 0 +nest d0dc0130bbaffff0 0 +nest d1d5db6a15028a9b 0 +nest d2085fbebccdffda 0 +nest d25a3ce67a83b39b 0 +nest d27009c85a85fc05 0 +nest d2b8b85442a71925 0 +nest d2d13b315fa7cda4 0 +nest d3c8d6b28ec1e96c 0 +nest d3f0565c6040da91 0 +nest d3fcd81e343f10ef 0 +nest d4885e4f3e20b47b 1 +nest d491b0153cfde2d8 0 +nest d4d8059fa01f1e3c 0 +nest d4dce2d2b3a5855d 0 +nest d4e70b54741103c0 0 +nest d50d180e3f8249f7 0 +nest d51295ab04b0d39c 0 +nest d560adb4a23e2cff 0 +nest d5b13308dc43b558 0 +nest d5bb33cd7f8fba6b 0 +nest d65071a443bfd4aa 0 +nest d72258d6542408c2 0 +nest d78a9a1b73a37bc2 0 +nest d7bdfd13e38c53eb 0 +nest d83135f37d441e8a 0 +nest d85fa2a1d24a1d9f 0 +nest d864f94da21ec08c 1 +nest d8bec32aecbb5851 0 +nest d8cb3bc23bf7e3c7 0 +nest d8e1d53e3c403ae2 0 +nest d900a7d57a7a6e51 0 +nest d94841dd9e08985b 0 +nest d9a72ef3652ab4bd 1 +nest d9b34ca129cedb0a 0 +nest da2f7f459c5ddf7f 0 +nest da42d957244e0f81 0 +nest da62006c9ee9330e 0 +nest da950ddf8dfe0436 0 +nest dafd603c2f091213 0 +nest db6e9f0bb941444e 0 +nest db7e32c39b011150 0 +nest dbee05807e339e8d 0 +nest dbf55aba3ae608cc 0 +nest dc57c1548a19f6ef 0 +nest dcd3b9c5600c74ac 0 +nest dce93392eeee89dd 0 +nest dd55181effa0995b 0 +nest dd7f4ba2505ff33c 0 +nest ddf05a9fda55b0c4 0 +nest de0c720a86e29e55 0 +nest de35fac2281b4eea 0 +nest de6229cf181cd846 0 +nest de6f49bdb8c48e7b 0 +nest de9df27d11fd6d43 0 +nest df0e0f92f2092438 0 +nest df22835a99ccf5bc 0 +nest e0d588512343fa21 0 +nest e10010a21b629e66 0 +nest e146ee1a4a342aac 0 +nest e1b4c1103fc2ed5b 0 +nest e23c2226eb4158af 0 +nest e2699fd6a3fa1328 0 +nest e2fe3265394778ce 0 +nest e300b1faf6e1d012 1 +nest e36cf8c9d9f1cd89 0 +nest e378ef5afbb3e408 0 +nest e3a90ae9514813f6 0 +nest e3ad8a7c6e96972c 0 +nest e3d2d3a21b154af6 0 +nest e40b81d45cf6363d 1 +nest e45f0d4802d2848a 0 +nest e46ab7a2d342d4e1 0 +nest e475358369fb36cd 0 +nest e49178f48c0574fa 1 +nest e4c073933f1e47b6 0 +nest e51a8d8ddd82a50d 0 +nest e53f1181d710f13f 0 +nest e57a61300c415efd 0 +nest e5eab62fa164f5df 0 +nest e6378805498ae207 0 +nest e65813db8d9b2bc6 0 +nest e672f75e6728a41b 0 +nest e6e4049fbb37155b 0 +nest e7afc20b0204158a 0 +nest e81adba88c94d542 0 +nest e81be21ddfb4b2ca 0 +nest e83f724256ae1c03 1 +nest e8bf877e84fd93c8 0 +nest e963d0798b6c2416 0 +nest e98c2bc92448636c 1 +nest ea67c8fe6f523c94 0 +nest eaaca8031d102a8a 0 +nest eabdb1c11c0b7344 0 +nest eaf3758011d7fd20 0 +nest eb21547e8fc5798d 0 +nest eb59730bea76712d 0 +nest ebd54b9559a8d1dc 0 +nest ebe6c7d83a7b0b88 0 +nest ec0a3b87d49de600 1 +nest ec23757266779a3d 1 +nest ec238b174e9bc2c2 0 +nest ec571d26837759c3 0 +nest ed0f421581be922c 0 +nest ed51829342734599 0 +nest ed5af324fbb452ce 0 +nest edc3a81a5285678e 0 +nest ee8b47838e145fcc 0 +nest ee9fc2ad102cff33 1 +nest ef0b1c5fa0b90ab9 0 +nest ef1d53b8c44bbb67 0 +nest ef29cc93418187ad 0 +nest efb4a906769bc50a 0 +nest f028f44bfc9220df 0 +nest f03f21e4f1638e0f 0 +nest f103a8f1cc586bbe 0 +nest f148d5a58bdea0ef 0 +nest f189dcfe4fda591e 0 +nest f197817f969bf7c7 0 +nest f1e3e5d2694a3263 0 +nest f2b1cab4ba10688d 0 +nest f2e357d4e331608a 0 +nest f3522319ec73e6b5 0 +nest f375a8b30995f676 0 +nest f38296f6730d0380 0 +nest f38d7d97db1cc3a5 1 +nest f4af891e3663df17 0 +nest f5da182a28999848 0 +nest f6499fa9b56dd88d 0 +nest f64f3b1838d23608 0 +nest f69b15b433a0ebb1 0 +nest f6cd3e68decce213 0 +nest f6d09aaa879795f5 0 +nest f740404a18ee9317 0 +nest f83dbf2f26b3d9d6 0 +nest f85e3c71bf15f382 0 +nest f8ff1b999d5139e6 0 +nest f90db1a16f62ee03 0 +nest f953ae6dc1284559 0 +nest f9710e2cab88cd14 0 +nest f9bbc08faef1cd9a 0 +nest f9f84d21d21eb570 0 +nest fa47b525839d3e9b 0 +nest fa6e14ca0bb22b76 0 +nest fb57d0b51a01ea3d 0 +nest fc41f933bfe74d4c 0 +nest fc7f155243219c03 0 +nest fd19ef11039692b7 0 +nest fd619dcdcef1e43e 0 +nest fde1a69149bde1e8 0 +nest fe09c2eea1d66805 0 +nest fe21950435627c9a 0 +nest fe7c1a71bbacd6c7 0 +nest fedbe7b3fffd467c 0 +nest fef21dd5727bbb51 0 +nest ffd1fc17b731e639 1 +nest ffec8b3317b77318 0 +params 4b733c4d83b07 0 +params 23f7b60bac46e6 0 +params 3db89571c08346 0 +params 1b18ff4bbe15dc8 0 +params 2340a4830b3aea6 1 +params 24f99e084d8eac7 0 +params 25992276d82d57d 0 +params 28c515cc9cf15ea 1 +params 292e78225cb9e42 0 +params 33b850c282de191 3 +params 3d91ed95503b07c 0 +params 404b9670f3a703b 0 +params 43c9282165db6d5 0 +params 53a30a545ebbaa8 0 +params 567a6c2fb1b14e3 1 +params 60be78b537264df 0 +params 6434baca4e727a3 0 +params 6593a5202605144 2 +params 6fa75d99a22b8f8 0 +params 75ca46ce0ff4da0 0 +params 7b978a659fba30c 0 +params 7cab3f5048545bb 0 +params 7ecd582e890ea31 0 +params 827933c0d01af9f 0 +params 8528c5691f65b73 0 +params 85e190a60167712 4 +params 8819a45d3577c0d 0 +params 8cfc26a65c57400 0 +params 90af57aa911e8c8 0 +params 921b5208a7d7443 0 +params 9290bff224c908f 2 +params 96de467da885077 0 +params 9ca30156b74ccee 0 +params 9d3f23b15ca9b60 0 +params a366a6cfe2d6884 0 +params b42253871667dfe 0 +params b50f1f49cd3855e 0 +params b516b97cb14b102 13 +params babbb34b92b7a3c 2 +params baff7355e29d862 1 +params c17f5e8b96c68e7 0 +params c7aa4beae78784c 1 +params cc857a81514720b 0 +params ce934e313754b1f 2 +params d2cc7984bc1c193 2 +params d2f8bf6bd734dbe 1 +params db814bc584f0227 0 +params dfda1f29948b476 0 +params e287345a1da751d 0 +params e76fa62254196bb 0 +params 10ab7d01d10ccbe2 1 +params 10d0776f5dc84500 0 +params 1102ee13c3f287bc 0 +params 119e3a9de1579038 0 +params 11b9cd4c327155e8 0 +params 11cdabaaa36de566 0 +params 11e1842b0846e3c2 5 +params 121769c14e306576 0 +params 123ef12c5471047c 0 +params 12d781289b1f6537 2 +params 130120439ca7e74e 0 +params 13e98b0aac2ac45a 0 +params 1439d5906edb93a5 0 +params 153e1fedb7354bc2 0 +params 1544b3bf28bbba4d 0 +params 15cd8d11c115e37d 1 +params 16b12a8a9877f141 2 +params 1706f0f1d4b7d5aa 0 +params 1784741bfa7af1fd 0 +params 190843bf16d81bd4 3 +params 1a3d08994f90d6f9 0 +params 1a8c427d3dec251d 3 +params 1a99603a2e88d654 0 +params 1aefd4b805333268 0 +params 1c1f924943890c8e 0 +params 1c470b2e35f931bf 0 +params 1c6db503caf6a152 0 +params 1cf5097d9b16f9b0 0 +params 1d00916cd2964c35 2 +params 1d08599882d7cfe7 0 +params 1d1c8eb3ae5168fe 0 +params 1d2fa22ed4ee9ff2 0 +params 1d5a11c7d1edeb11 0 +params 1db269061a925799 0 +params 1e539f3e54012138 2 +params 1e6ca46d5635c401 0 +params 1e8a64d9423479a1 0 +params 1ec2fb826c707c29 0 +params 1f2b736eb17cf738 0 +params 1fc0fedd54c8e75b 0 +params 20305a32e94c6e20 0 +params 209b66ed45486485 5 +params 215d259a676f1c6b 0 +params 21b214aba3517cf9 0 +params 21dbd83072a7a2b0 0 +params 220a016c123269d2 3 +params 22f146b9d55161f5 0 +params 233ed227bd9ad4ec 2 +params 23a4ccb986e9c241 0 +params 23b429d4f8393438 1 +params 242bbb0e5b9ec108 0 +params 242d896b0833bcf6 0 +params 2449a169abfc5709 1 +params 248d8aea5119d112 0 +params 24d563a0bd0284ee 0 +params 24d61732a772f6ce 0 +params 258ed6b3635d219f 0 +params 25b33c65ec17c605 0 +params 265eeb107f4f7037 2 +params 26600eca83f91cdc 6 +params 2694ccc9cd9c2e4a 0 +params 26f2906b4561e5ed 0 +params 270e54f53f0dec25 0 +params 2765d6c249664ab6 0 +params 27af60e478606323 0 +params 27f05b6efba965c8 0 +params 27fcbd42c188ec3e 0 +params 28a6e8c239e04047 0 +params 2909b7f6c9f5d18d 0 +params 29167175f22af761 1 +params 297a4160638db8fd 1 +params 29b64a559ea4ffff 0 +params 29d1bc2a36afc76d 0 +params 29f5a11bbf71babc 0 +params 2a14568b12903218 0 +params 2a69b5e495fafcb6 0 +params 2a9e43e7514907ba 3 +params 2ae26defd322202f 0 +params 2afac393e3eb18c6 0 +params 2b3b72e3b3627a3a 6 +params 2b7f42fc1c74594d 0 +params 2b81eacbca431848 0 +params 2c77117a0e84cfe1 0 +params 2d4f0807a4377e66 0 +params 2d6194ec9f73238e 0 +params 2d8466f6954e126e 0 +params 2d96edc3799baa0e 0 +params 2dd16cc3aa6f6523 0 +params 2e0b841baad1468e 0 +params 2e395ab5adfaffe4 0 +params 2e8b4e73d01cda1d 3 +params 2e8f98f9891a8cba 0 +params 2ec94c46492bdf4e 0 +params 2ee347526cbe8582 13 +params 2f0b87d442d670ab 0 +params 2f379cdbd5b7baad 0 +params 2f48393189526db5 0 +params 2f750d28b00ff12a 0 +params 2f8af7800010622b 1 +params 306ff4fa969c2b5e 0 +params 30823f7b3a383b0b 3 +params 30b49386bfe531be 0 +params 30b5d92358dcd253 0 +params 30fdfead3bc13677 0 +params 323f04fd5a7a671a 0 +params 324c715b3ef59fb5 1 +params 334ac2095aeeffb5 0 +params 3440f8d589cae291 0 +params 3455adbedae84514 0 +params 345a2fdfacb84efa 3 +params 34a9957d1f0f0b52 0 +params 3529f53b490c717a 0 +params 3599e84ad64ea3a0 0 +params 35d9e976f25eecfb 0 +params 3677e09eb4196cd4 0 +params 36aff0a235db6e64 0 +params 36eb5585be49ea31 1 +params 374dc0d9439e660e 0 +params 378070cac6c29d8a 0 +params 37d1819c814b6e48 0 +params 37e6c1a9572eed09 0 +params 37f14128572d051e 0 +params 37fde5021a0194e9 3 +params 3817c48d6d009d40 0 +params 3844be8089f457b2 0 +params 386a103486cff027 0 +params 388245783c0938fa 0 +params 38b9413e0cbbaac0 0 +params 38d2574e03270d77 0 +params 39aa49c948c80691 0 +params 39dc70474f1885ae 0 +params 39e896bec2ab77c9 0 +params 3a01c0eb5dcd7613 0 +params 3ac893ff638f771b 0 +params 3ad308b61b8f874e 2 +params 3b670e3e6581709f 2 +params 3b6d5a0ea9a511e9 0 +params 3bb6aca50f06a606 0 +params 3c3d72419cba9309 0 +params 3ce01651a41aafb6 3 +params 3d13afc5e29e371a 0 +params 3d8282d048efcc77 0 +params 3d903cc2ced5fe66 2 +params 3e185994d574529f 0 +params 3eede00189a7a15f 0 +params 3f6dfe71df011044 3 +params 3fd29f21cbad3628 0 +params 3fd493d0cfa3199c 0 +params 403c374008b3781f 0 +params 4051d8f6c2af839f 1 +params 410fa58f10ab2614 0 +params 412954cd14f67658 0 +params 412e3e27d4215e06 9 +params 41673bb8d8375d55 0 +params 418b8560d8501615 0 +params 419553ea1dcb7cbb 0 +params 41c0961bc8c2cab7 0 +params 41d3ef4759cf2df9 0 +params 41d5fcfae22584c4 0 +params 420f8a970c0221f5 0 +params 42b8290f3fed3450 3 +params 42c814d98094dbb7 0 +params 430cba9d0ec3ab56 0 +params 437e4d914b709dbe 0 +params 4425f307731f789f 0 +params 447dd766bab76e3d 0 +params 44c0fb3e0547cf06 0 +params 44e4a85260ed66f3 2 +params 4581af238c881954 2 +params 45df799f4c412c1e 0 +params 467a56bed6f21356 0 +params 468e7a55aa64e481 0 +params 468f6db3ee1d572b 0 +params 46fbd074054bea23 0 +params 470d78a9487c1c79 0 +params 472324ee9732f077 1 +params 473790676e54eb23 0 +params 479d7c2ae4e70e80 3 +params 47f97c0fb37c8568 3 +params 47fa33217335c644 0 +params 482cd3901a33f7da 0 +params 4849325cb38239ae 0 +params 48eb495d1a981e3b 0 +params 492eb95e1d2c9566 0 +params 49ce64a0c7a6ba56 0 +params 4a5b1dd8e915b277 0 +params 4ad0bfd06eca3b10 4 +params 4afaa3ee804f31a3 1 +params 4c031399d70c63d6 3 +params 4c2c8773e6afbfc3 2 +params 4c2caaf3d6ca8c78 0 +params 4ca0617355962ae6 0 +params 4ca9fa48f6567768 0 +params 4cd5d6d5bc533efc 0 +params 4d1f25b9f88ce40a 0 +params 4dd0e56fa6b1cd26 0 +params 4eb915efc09067e9 1 +params 4f0a11cd12ebdd64 0 +params 4f1eeefcc6361836 0 +params 5016e2cec581b778 0 +params 5098ceb6ba93f0f1 0 +params 513a82879f6988a3 3 +params 528f29df5a342dec 0 +params 532d4763ee4f0d51 0 +params 5349e49cedd311d3 0 +params 539c00ef45a998b6 0 +params 554719c9558ee90c 0 +params 5567796a9df49bf1 2 +params 5690a7e6f4b931fa 0 +params 56d3eb4a00f6de55 0 +params 56eccd261ee32406 0 +params 57118e22b1dee7aa 10 +params 5738d7b2ce7e1fb1 0 +params 57aaa7dc54b71ee5 0 +params 57cae2ed030dc221 2 +params 5825699eb9713c9b 0 +params 5844b083ab372a93 1 +params 58b6201c2abb3c2d 0 +params 58c2008305b0afe0 3 +params 58ceb8247ac5a5e8 0 +params 596a40ae7adf3b66 2 +params 597bcfac2e1bfc60 0 +params 59fafb2628d5e22c 0 +params 5b7d514864a7fe74 0 +params 5ba349d3f9dbe981 0 +params 5ba413ec871b1a05 0 +params 5bcbc43e0bfc1c04 0 +params 5bef2751b97cdaf8 0 +params 5c427223e4f7bd31 0 +params 5c62bc4336a92380 0 +params 5c976139ae63e37e 3 +params 5cb29ea4b16ee5b4 0 +params 5cff4150fa39e9ab 2 +params 5d8dea77474eef46 2 +params 5dc5ca231ce7af8c 0 +params 5ddce9f50b0c275d 0 +params 5e6955ad62fc35dd 4 +params 5f4c2b1da49767c2 3 +params 5faf45317652eb97 0 +params 5fca1dab3eb84ff6 0 +params 5fcec82716ef1539 0 +params 60ae7b12db9b9c97 0 +params 60fdd1aa679748b0 0 +params 610775902254026b 0 +params 61fe78aa31fda57a 2 +params 62118ff9d5625689 0 +params 62c4c7c808ddb517 0 +params 63d5ec100a00a495 0 +params 643e391062d5ad98 1 +params 64cee7e94959c37e 0 +params 65401e97a7b3dde3 2 +params 6619eb15218ab6f4 0 +params 662cc3aa77fb9b46 1 +params 670c6a44b9bdada6 2 +params 672566a3610ffd5f 0 +params 67a27cf55608f473 1 +params 681cdc3561eb7d64 0 +params 689179ee148b90d7 0 +params 68e893e8f3d71fe9 0 +params 690e7d5a20a3fe48 0 +params 696611d56fdfeda5 0 +params 6a437d0c0e83cacb 0 +params 6b5abc249db062e7 2 +params 6ba0e82f39ba4f6a 3 +params 6c08a8d51137d60d 0 +params 6c0907eac176dd5f 3 +params 6cbc12bcc60a4e8d 0 +params 6ce6bd5b9c4a630f 0 +params 6d661a0458d987d3 0 +params 6d89a0375f525b9b 2 +params 6e594189eafe0408 1 +params 6e7d533058e61f70 0 +params 6e8de06cc7d7c1b9 0 +params 6e9f1d938c50aeaa 0 +params 6ed1fd271b188d46 0 +params 6f12044892549b6a 0 +params 6f6b77ffc341ac63 0 +params 6f92d0e9fea66324 1 +params 701d98e1ee6bcead 0 +params 703eae2147b6debd 7 +params 709961d34ae00031 1 +params 70acb27eb42344f9 0 +params 70f36ffdb4452f92 2 +params 718940229dd98516 0 +params 71d2cb7f1d1a951f 2 +params 72e0017a3e9b4ca1 0 +params 72ef52b7bd594829 2 +params 72f4fe268d6cb29a 0 +params 735d655f4fb0fd03 0 +params 737bc925aefcc6c5 0 +params 7385fecc2a9f3960 0 +params 73901c5b41eaae62 0 +params 73e074eadb44c9f7 3 +params 73e72ba081fc59e2 0 +params 73f11932d9093d6f 0 +params 7464bc75ffa4b7c6 0 +params 74798dc894205762 4 +params 752011234791fb28 0 +params 755a08e70176b633 0 +params 7594fbf007da4c74 0 +params 75c8bef4f03819be 0 +params 75eb9f6f072bdcb4 0 +params 766d82bfd86dd118 0 +params 76c1f41550b1b997 1 +params 76dc0bb4769da62d 3 +params 776b8b1eab8668f4 0 +params 7791b74c1fceaf2a 6 +params 77fbb3c024e536a6 0 +params 7869708e768f836d 0 +params 78af546e01ca37a8 0 +params 795d777e64164e3c 1 +params 79a9c21686684680 0 +params 7a2991b69512efe4 3 +params 7af16e2b108a072b 1 +params 7b38b69469da3083 0 +params 7bbbeff107c17bde 0 +params 7c0c8d55f5c503d0 0 +params 7cbebea3fdb2d0f2 1 +params 7d74b2ed6c067b53 0 +params 7e30b28a733bf314 1 +params 7e6fd84a59cb93e2 1 +params 7e865b52c71df10f 0 +params 7f09d0dc03338350 0 +params 7f0dfcb8e7044238 0 +params 7f303a84eac3193d 9 +params 7f4d61b6e5398157 0 +params 80916eb377aa30c0 0 +params 809f43336ad03a2a 3 +params 80e0733dd1dd8967 0 +params 8145a5f15c149225 0 +params 81bdfa9c95a47411 3 +params 81cb89a3777fa95b 0 +params 823b34f6ccc08503 1 +params 82789957ab0d60dd 0 +params 8344f0e335b7777f 0 +params 836124753f3d43ab 0 +params 83617b448ae4f5b9 0 +params 83bcd9fa7921e9d3 0 +params 841f41fa7a532342 2 +params 843711ddc2000a69 0 +params 8444bfbbeb4b82aa 0 +params 8448f217eadf0cb2 1 +params 84f0a5079e308d96 0 +params 85a1cf5cc0f384f6 0 +params 85a803fc71028fe4 0 +params 860c6bcc0adecb82 0 +params 861e750aa9a0a707 3 +params 86216d2ceafc4984 0 +params 8634268abbb0b083 0 +params 8642302fa250c337 3 +params 86432dad9ce7d6cb 2 +params 86a231892fd2185e 0 +params 86a3f7bdf62de7a0 0 +params 86bb4d97aa1153b2 0 +params 86d9ce774ac0e9b2 1 +params 873c2b8fb5cc9d7b 0 +params 8751c283bf5503b0 2 +params 882bbb80b4c33a2a 0 +params 8834877c9b15df09 0 +params 888095969ea9ab92 0 +params 89776c50b7500989 0 +params 89a3b60759c7a791 0 +params 89f9967795227eb9 0 +params 8a7b0fc3a462a5d6 0 +params 8a917b2e79abf10b 0 +params 8bfad7e73ac0394e 1 +params 8ce48499c2224772 2 +params 8d0b4c6c4e0b8287 1 +params 8d5b6014948b0f22 0 +params 8d74a3b2fe7eb966 0 +params 8d9a330126fc06e2 0 +params 8d9a862e3af62f77 0 +params 8de5991c8f804dc9 0 +params 8e8cf68def8d9e0e 0 +params 8ed2c4409443dda9 0 +params 8f9de2b50c6d0080 0 +params 8feab80bf0370f32 0 +params 8ff96a7fddf76be1 1 +params 910bfa248b2227af 0 +params 917b56d82c963db9 2 +params 917beb804a058565 0 +params 919157b8dd21ebc5 0 +params 91dcc2e5054d5bab 3 +params 925ba93869af880e 0 +params 92938b5bcf3574b7 0 +params 93323bfcca74ac92 0 +params 939d426bd40f34bb 0 +params 93b9d305982206ad 1 +params 93bf0fd6213a7b84 0 +params 9407348f6ee68f78 4 +params 947bae210f6a7031 0 +params 949ba1dba9a922ee 0 +params 94aad1a9ad691252 0 +params 954ed37fdd30474a 0 +params 956291fcc7f26916 1 +params 964723e6313d848e 0 +params 966a40fe1711ccf0 0 +params 96dfca7e24aaf25d 1 +params 9777dc82c98426b8 3 +params 977bf6fbb79350d3 0 +params 979f53be1f68fa7b 2 +params 97f4e74e9c4d232b 0 +params 97fb9fe5299fca74 0 +params 9854a9f84be0722e 0 +params 989c038c6c9c954b 5 +params 99054039062a6d56 0 +params 9a08845a5a2b39d5 0 +params 9aadf5d88db176de 0 +params 9b00b22bd2dfcdea 1 +params 9b15492f093e9680 0 +params 9b675b24201a7db1 1 +params 9c0e8638585adacf 3 +params 9c2fdbb657785b23 0 +params 9cf542d698798567 0 +params 9d0250cf760a1cb1 0 +params 9da180752e6d8e5c 0 +params 9db9edf9dc3ccc47 0 +params 9dfcef85a5056fc8 0 +params 9e1070f26a7961f5 0 +params 9ea88db302fadbad 0 +params 9ef916a9ee01d457 0 +params 9f4839d437dbdb32 0 +params 9fe93961fce54ce4 0 +params 9ff084684afeb19e 0 +params a02c8cbee597d352 0 +params a04d750c20c1a89a 0 +params a077984a0c213988 0 +params a0a05b5b3bae9287 0 +params a0cb16a743107fec 0 +params a0f0fea3368f2393 0 +params a103963d54dbd311 0 +params a17366facb51952d 0 +params a188cfc59cab8daf 0 +params a1b43eb494b1fcd0 0 +params a2723c0c456ffc48 0 +params a2750be13ac0ee82 0 +params a2947b425b951a72 3 +params a2c7a6bff645bd0b 0 +params a2ea72ba4256d1ad 0 +params a3135ee44b475bac 0 +params a3c2bb108cce8ba0 2 +params a3f0563f01c103d7 0 +params a41d39c5795ca771 1 +params a508a4b30b2f79a8 2 +params a525edb704ff846a 1 +params a5ae71540871bba8 3 +params a604a99ead07e8b7 7 +params a62599079371da08 0 +params a649d84498c126fb 0 +params a6575e6a24b93565 3 +params a7d38a0353d337b6 2 +params a9956363396fd78f 0 +params aa7daecde0716211 0 +params aa8df295f2993c33 0 +params aafe6cbcf5a00afa 0 +params ab7a128a7db81697 0 +params ab8c28087d812326 0 +params abb36acf86e05f44 3 +params abfbbb2620e0ae26 0 +params ac2dcd57d0e40382 2 +params ac5f9599e49c6015 2 +params ad36ead5dc5ed87e 0 +params adb4262180fbbfa3 0 +params aea683ac23a9c3c2 0 +params aee6e643327d448c 2 +params aee9bf52c500e4ed 0 +params af029cbd84ef0056 3 +params af2de214b51708e1 2 +params b0176088079c92c2 2 +params b02160df0862ef04 0 +params b0a2e27b2dbbb205 0 +params b0ccffd9ae0581de 0 +params b13dd85e39366f9b 0 +params b16c5ae4b8177443 0 +params b1ca8c69bcb29f4c 0 +params b25689d132f6a3aa 0 +params b43807588b3d388c 2 +params b550f7063b5e1658 0 +params b55f7472956eb256 0 +params b5ee57928f707a5b 0 +params b5fb452f795be5ae 0 +params b6774bf12793f446 1 +params b70a9e2dff98d690 0 +params b873090225df1db0 1 +params b8c3995c4bdabe95 0 +params b8e9148c718a83b2 2 +params b926ca3a7fe53ba2 2 +params b92a296420f44bb4 3 +params b999d958d4795300 0 +params b99d7ec7f2aaacc4 1 +params ba063e007e4a0089 0 +params ba0efed99d11207b 0 +params bac2ec39302668c7 3 +params bad8f1522ba80ebe 0 +params bafd9e14f2744298 0 +params bb14250915d7445f 0 +params bb4bef0b64fd8d48 0 +params bb70288b5c1a5ab3 0 +params bb774894bae68dfb 3 +params bb7a2eb4b3663ef8 2 +params bba7ed99af8f5b54 0 +params bbd0138ed7a0d350 0 +params bbee458ed08455f3 0 +params bc12021c4f16c7b1 0 +params bc2a33a973f536c4 0 +params bcb1ccbb00ea585f 0 +params bcdea3156f45a122 0 +params bd028205e65b668b 3 +params bd1086a51723f0a9 0 +params bd409f1d90d1ec7e 0 +params bd99db1ff250e606 2 +params bda35bf5d19afe35 2 +params bde9492df89e79bb 0 +params bdfa2256a1243766 0 +params be1c296b4103541a 0 +params be5051b47f524a38 0 +params be608bd98b316613 0 +params be6f1003b9147c1e 0 +params be9f5fbfbcf49f3b 2 +params beab83a2a041144e 0 +params bfc39e13eac109b5 0 +params bfe88aff0d1cf1ac 0 +params c04451c38eea2f3d 0 +params c0f637cf0b098e2e 2 +params c12ea4bb6c4307ad 0 +params c141ab01fc2a3cd6 0 +params c1b86e1466d682c2 0 +params c1d80e01a94fb0d5 1 +params c2b7d06e06237be2 0 +params c372db7ad947ef02 5 +params c37c3f7fe77cffe5 0 +params c5ae5fa47a4cf64f 0 +params c5b0ee41b05acfb2 3 +params c60ec2d90df2762b 3 +params c6a6785521523225 2 +params c77b05daa4ff56ae 1 +params c7a2188f77229408 0 +params c83cc8417e632ce2 0 +params c853f415c89a1d6a 0 +params c85f0d56aa6253b5 2 +params c8c1c712735b0ccc 0 +params c90f6570abc63716 1 +params c9417f90820411a2 0 +params c972eb6e158540ea 0 +params c99075537de59d30 2 +params ca51003e7d5cf837 0 +params ca612ead4b39d244 3 +params cb0370a917abe0df 0 +params cb488f245b2795da 0 +params cba3c34ae22ec49b 0 +params cbaf49d9aa662ec8 0 +params cc256dd80b037384 1 +params cc329731922ce601 1 +params cc383f93cc4a1177 0 +params cc527f122cca2eee 0 +params cc71575aafbcac28 0 +params cd44a949d2c08a9d 1 +params cd514d3bf7b25bba 2 +params cdbea33e56e46c5a 0 +params ce20725a9f915d01 0 +params ce275778f4d4977d 3 +params ce288f34f8e6e4af 2 +params cf024f76b08afd38 4 +params cfdc0c8bc7aaaf12 0 +params d05a659360ba6f3c 0 +params d0dc0130bbaffff0 0 +params d1d5db6a15028a9b 0 +params d2085fbebccdffda 0 +params d25a3ce67a83b39b 0 +params d27009c85a85fc05 2 +params d2b8b85442a71925 0 +params d2d13b315fa7cda4 0 +params d3c8d6b28ec1e96c 0 +params d3f0565c6040da91 0 +params d3fcd81e343f10ef 0 +params d4885e4f3e20b47b 3 +params d491b0153cfde2d8 0 +params d4d8059fa01f1e3c 0 +params d4dce2d2b3a5855d 0 +params d4e70b54741103c0 0 +params d50d180e3f8249f7 0 +params d51295ab04b0d39c 0 +params d560adb4a23e2cff 0 +params d5b13308dc43b558 1 +params d5bb33cd7f8fba6b 0 +params d65071a443bfd4aa 0 +params d72258d6542408c2 0 +params d78a9a1b73a37bc2 0 +params d7bdfd13e38c53eb 0 +params d83135f37d441e8a 3 +params d85fa2a1d24a1d9f 0 +params d864f94da21ec08c 2 +params d8bec32aecbb5851 0 +params d8cb3bc23bf7e3c7 0 +params d8e1d53e3c403ae2 0 +params d900a7d57a7a6e51 0 +params d94841dd9e08985b 0 +params d9a72ef3652ab4bd 2 +params d9b34ca129cedb0a 0 +params da2f7f459c5ddf7f 0 +params da42d957244e0f81 1 +params da62006c9ee9330e 0 +params da950ddf8dfe0436 0 +params dafd603c2f091213 0 +params db6e9f0bb941444e 2 +params db7e32c39b011150 0 +params dbee05807e339e8d 0 +params dbf55aba3ae608cc 0 +params dc57c1548a19f6ef 1 +params dcd3b9c5600c74ac 0 +params dce93392eeee89dd 0 +params dd55181effa0995b 0 +params dd7f4ba2505ff33c 0 +params ddf05a9fda55b0c4 0 +params de0c720a86e29e55 0 +params de35fac2281b4eea 0 +params de6229cf181cd846 0 +params de6f49bdb8c48e7b 0 +params de9df27d11fd6d43 0 +params df0e0f92f2092438 0 +params df22835a99ccf5bc 0 +params e0d588512343fa21 0 +params e10010a21b629e66 1 +params e146ee1a4a342aac 0 +params e1b4c1103fc2ed5b 0 +params e23c2226eb4158af 0 +params e2699fd6a3fa1328 0 +params e2fe3265394778ce 0 +params e300b1faf6e1d012 1 +params e36cf8c9d9f1cd89 0 +params e378ef5afbb3e408 0 +params e3a90ae9514813f6 0 +params e3ad8a7c6e96972c 0 +params e3d2d3a21b154af6 0 +params e40b81d45cf6363d 3 +params e45f0d4802d2848a 2 +params e46ab7a2d342d4e1 2 +params e475358369fb36cd 0 +params e49178f48c0574fa 0 +params e4c073933f1e47b6 0 +params e51a8d8ddd82a50d 1 +params e53f1181d710f13f 0 +params e57a61300c415efd 0 +params e5eab62fa164f5df 3 +params e6378805498ae207 0 +params e65813db8d9b2bc6 0 +params e672f75e6728a41b 0 +params e6e4049fbb37155b 0 +params e7afc20b0204158a 3 +params e81adba88c94d542 1 +params e81be21ddfb4b2ca 0 +params e83f724256ae1c03 4 +params e8bf877e84fd93c8 3 +params e963d0798b6c2416 0 +params e98c2bc92448636c 3 +params ea67c8fe6f523c94 0 +params eaaca8031d102a8a 0 +params eabdb1c11c0b7344 1 +params eaf3758011d7fd20 0 +params eb21547e8fc5798d 0 +params eb59730bea76712d 0 +params ebd54b9559a8d1dc 0 +params ebe6c7d83a7b0b88 0 +params ec0a3b87d49de600 2 +params ec23757266779a3d 2 +params ec238b174e9bc2c2 0 +params ec571d26837759c3 0 +params ed0f421581be922c 0 +params ed51829342734599 0 +params ed5af324fbb452ce 0 +params edc3a81a5285678e 0 +params ee8b47838e145fcc 0 +params ee9fc2ad102cff33 3 +params ef0b1c5fa0b90ab9 0 +params ef1d53b8c44bbb67 0 +params ef29cc93418187ad 0 +params efb4a906769bc50a 0 +params f028f44bfc9220df 0 +params f03f21e4f1638e0f 0 +params f103a8f1cc586bbe 0 +params f148d5a58bdea0ef 0 +params f189dcfe4fda591e 0 +params f197817f969bf7c7 0 +params f1e3e5d2694a3263 0 +params f2b1cab4ba10688d 0 +params f2e357d4e331608a 0 +params f3522319ec73e6b5 0 +params f375a8b30995f676 0 +params f38296f6730d0380 0 +params f38d7d97db1cc3a5 3 +params f4af891e3663df17 0 +params f5da182a28999848 0 +params f6499fa9b56dd88d 1 +params f64f3b1838d23608 0 +params f69b15b433a0ebb1 0 +params f6cd3e68decce213 0 +params f6d09aaa879795f5 1 +params f740404a18ee9317 0 +params f83dbf2f26b3d9d6 0 +params f85e3c71bf15f382 0 +params f8ff1b999d5139e6 2 +params f90db1a16f62ee03 0 +params f953ae6dc1284559 0 +params f9710e2cab88cd14 0 +params f9bbc08faef1cd9a 4 +params f9f84d21d21eb570 0 +params fa47b525839d3e9b 0 +params fa6e14ca0bb22b76 0 +params fb57d0b51a01ea3d 3 +params fc41f933bfe74d4c 0 +params fc7f155243219c03 0 +params fd19ef11039692b7 0 +params fd619dcdcef1e43e 0 +params fde1a69149bde1e8 0 +params fe09c2eea1d66805 0 +params fe21950435627c9a 0 +params fe7c1a71bbacd6c7 0 +params fedbe7b3fffd467c 0 +params fef21dd5727bbb51 0 +params ffd1fc17b731e639 4 +params ffec8b3317b77318 2 +mask baff7355e29d862 1 +mask 345a2fdfacb84efa 1 +mask 61fe78aa31fda57a 1 +mask 65401e97a7b3dde3 1 +defs 4b733c4d83b07 1 +defs 23f7b60bac46e6 1 +defs 3db89571c08346 1 +defs 1b18ff4bbe15dc8 1 +defs 2340a4830b3aea6 1 +defs 24f99e084d8eac7 1 +defs 25992276d82d57d 1 +defs 28c515cc9cf15ea 1 +defs 292e78225cb9e42 1 +defs 33b850c282de191 1 +defs 3d91ed95503b07c 1 +defs 404b9670f3a703b 1 +defs 43c9282165db6d5 1 +defs 53a30a545ebbaa8 1 +defs 567a6c2fb1b14e3 1 +defs 60be78b537264df 1 +defs 6434baca4e727a3 1 +defs 6593a5202605144 1 +defs 6fa75d99a22b8f8 1 +defs 75ca46ce0ff4da0 1 +defs 7b978a659fba30c 1 +defs 7cab3f5048545bb 7 +defs 7ecd582e890ea31 1 +defs 827933c0d01af9f 1 +defs 8528c5691f65b73 1 +defs 85e190a60167712 1 +defs 8819a45d3577c0d 1 +defs 8cfc26a65c57400 1 +defs 90af57aa911e8c8 1 +defs 921b5208a7d7443 1 +defs 9290bff224c908f 1 +defs 96de467da885077 1 +defs 9ca30156b74ccee 1 +defs 9d3f23b15ca9b60 1 +defs a366a6cfe2d6884 1 +defs b42253871667dfe 1 +defs b50f1f49cd3855e 1 +defs b516b97cb14b102 1 +defs babbb34b92b7a3c 1 +defs baff7355e29d862 1 +defs c17f5e8b96c68e7 1 +defs c7aa4beae78784c 1 +defs cc857a81514720b 1 +defs ce934e313754b1f 1 +defs d2cc7984bc1c193 1 +defs d2f8bf6bd734dbe 1 +defs db814bc584f0227 1 +defs dfda1f29948b476 1 +defs e287345a1da751d 1 +defs e76fa62254196bb 1 +defs 10ab7d01d10ccbe2 1 +defs 10d0776f5dc84500 1 +defs 1102ee13c3f287bc 1 +defs 119e3a9de1579038 1 +defs 11b9cd4c327155e8 3 +defs 11cdabaaa36de566 1 +defs 11e1842b0846e3c2 1 +defs 121769c14e306576 1 +defs 123ef12c5471047c 2 +defs 12d781289b1f6537 1 +defs 130120439ca7e74e 1 +defs 13e98b0aac2ac45a 1 +defs 1439d5906edb93a5 1 +defs 153e1fedb7354bc2 1 +defs 1544b3bf28bbba4d 1 +defs 15cd8d11c115e37d 1 +defs 16b12a8a9877f141 1 +defs 1706f0f1d4b7d5aa 1 +defs 1784741bfa7af1fd 1 +defs 190843bf16d81bd4 1 +defs 1a3d08994f90d6f9 1 +defs 1a8c427d3dec251d 1 +defs 1a99603a2e88d654 1 +defs 1aefd4b805333268 1 +defs 1c1f924943890c8e 1 +defs 1c470b2e35f931bf 1 +defs 1c6db503caf6a152 1 +defs 1cf5097d9b16f9b0 1 +defs 1d00916cd2964c35 1 +defs 1d08599882d7cfe7 1 +defs 1d1c8eb3ae5168fe 1 +defs 1d2fa22ed4ee9ff2 1 +defs 1d5a11c7d1edeb11 1 +defs 1db269061a925799 1 +defs 1e539f3e54012138 1 +defs 1e6ca46d5635c401 1 +defs 1e8a64d9423479a1 1 +defs 1ec2fb826c707c29 1 +defs 1f2b736eb17cf738 2 +defs 1fc0fedd54c8e75b 1 +defs 20305a32e94c6e20 1 +defs 209b66ed45486485 1 +defs 215d259a676f1c6b 1 +defs 21b214aba3517cf9 1 +defs 21dbd83072a7a2b0 1 +defs 220a016c123269d2 1 +defs 22f146b9d55161f5 1 +defs 233ed227bd9ad4ec 1 +defs 23a4ccb986e9c241 1 +defs 23b429d4f8393438 1 +defs 242bbb0e5b9ec108 1 +defs 242d896b0833bcf6 1 +defs 2449a169abfc5709 1 +defs 248d8aea5119d112 1 +defs 24d563a0bd0284ee 1 +defs 24d61732a772f6ce 1 +defs 258ed6b3635d219f 1 +defs 25b33c65ec17c605 1 +defs 265eeb107f4f7037 1 +defs 26600eca83f91cdc 1 +defs 2694ccc9cd9c2e4a 1 +defs 26f2906b4561e5ed 1 +defs 270e54f53f0dec25 1 +defs 2765d6c249664ab6 1 +defs 27af60e478606323 1 +defs 27f05b6efba965c8 1 +defs 27fcbd42c188ec3e 1 +defs 28a6e8c239e04047 1 +defs 2909b7f6c9f5d18d 1 +defs 29167175f22af761 1 +defs 297a4160638db8fd 1 +defs 29b64a559ea4ffff 1 +defs 29d1bc2a36afc76d 1 +defs 29f5a11bbf71babc 1 +defs 2a14568b12903218 1 +defs 2a69b5e495fafcb6 1 +defs 2a9e43e7514907ba 1 +defs 2ae26defd322202f 1 +defs 2afac393e3eb18c6 1 +defs 2b3b72e3b3627a3a 1 +defs 2b7f42fc1c74594d 1 +defs 2b81eacbca431848 1 +defs 2c77117a0e84cfe1 1 +defs 2d4f0807a4377e66 1 +defs 2d6194ec9f73238e 1 +defs 2d8466f6954e126e 1 +defs 2d96edc3799baa0e 6 +defs 2dd16cc3aa6f6523 1 +defs 2e0b841baad1468e 1 +defs 2e395ab5adfaffe4 1 +defs 2e8b4e73d01cda1d 1 +defs 2e8f98f9891a8cba 1 +defs 2ec94c46492bdf4e 1 +defs 2ee347526cbe8582 1 +defs 2f0b87d442d670ab 1 +defs 2f379cdbd5b7baad 1 +defs 2f48393189526db5 1 +defs 2f750d28b00ff12a 1 +defs 2f8af7800010622b 1 +defs 306ff4fa969c2b5e 1 +defs 30823f7b3a383b0b 1 +defs 30b49386bfe531be 1 +defs 30b5d92358dcd253 1 +defs 30fdfead3bc13677 1 +defs 323f04fd5a7a671a 1 +defs 324c715b3ef59fb5 1 +defs 334ac2095aeeffb5 1 +defs 3440f8d589cae291 1 +defs 3455adbedae84514 1 +defs 345a2fdfacb84efa 1 +defs 34a9957d1f0f0b52 1 +defs 3529f53b490c717a 1 +defs 3599e84ad64ea3a0 1 +defs 35d9e976f25eecfb 1 +defs 3677e09eb4196cd4 1 +defs 36aff0a235db6e64 1 +defs 36eb5585be49ea31 1 +defs 374dc0d9439e660e 1 +defs 378070cac6c29d8a 1 +defs 37d1819c814b6e48 1 +defs 37e6c1a9572eed09 1 +defs 37f14128572d051e 1 +defs 37fde5021a0194e9 1 +defs 3817c48d6d009d40 1 +defs 3844be8089f457b2 1 +defs 386a103486cff027 1 +defs 388245783c0938fa 1 +defs 38b9413e0cbbaac0 1 +defs 38d2574e03270d77 1 +defs 39aa49c948c80691 1 +defs 39dc70474f1885ae 1 +defs 39e896bec2ab77c9 1 +defs 3a01c0eb5dcd7613 1 +defs 3ac893ff638f771b 1 +defs 3ad308b61b8f874e 1 +defs 3b670e3e6581709f 1 +defs 3b6d5a0ea9a511e9 1 +defs 3bb6aca50f06a606 1 +defs 3c3d72419cba9309 1 +defs 3ce01651a41aafb6 1 +defs 3d13afc5e29e371a 1 +defs 3d8282d048efcc77 1 +defs 3d903cc2ced5fe66 1 +defs 3e185994d574529f 1 +defs 3eede00189a7a15f 1 +defs 3f6dfe71df011044 1 +defs 3fd29f21cbad3628 1 +defs 3fd493d0cfa3199c 1 +defs 403c374008b3781f 1 +defs 4051d8f6c2af839f 1 +defs 410fa58f10ab2614 1 +defs 412954cd14f67658 1 +defs 412e3e27d4215e06 1 +defs 41673bb8d8375d55 1 +defs 418b8560d8501615 1 +defs 419553ea1dcb7cbb 1 +defs 41c0961bc8c2cab7 1 +defs 41d3ef4759cf2df9 1 +defs 41d5fcfae22584c4 1 +defs 420f8a970c0221f5 1 +defs 42b8290f3fed3450 1 +defs 42c814d98094dbb7 1 +defs 430cba9d0ec3ab56 1 +defs 437e4d914b709dbe 1 +defs 4425f307731f789f 7 +defs 447dd766bab76e3d 1 +defs 44c0fb3e0547cf06 1 +defs 44e4a85260ed66f3 1 +defs 4581af238c881954 1 +defs 45df799f4c412c1e 1 +defs 467a56bed6f21356 1 +defs 468e7a55aa64e481 1 +defs 468f6db3ee1d572b 1 +defs 46fbd074054bea23 1 +defs 470d78a9487c1c79 1 +defs 472324ee9732f077 1 +defs 473790676e54eb23 1 +defs 479d7c2ae4e70e80 1 +defs 47f97c0fb37c8568 1 +defs 47fa33217335c644 1 +defs 482cd3901a33f7da 1 +defs 4849325cb38239ae 4 +defs 48eb495d1a981e3b 1 +defs 492eb95e1d2c9566 1 +defs 49ce64a0c7a6ba56 1 +defs 4a5b1dd8e915b277 1 +defs 4ad0bfd06eca3b10 1 +defs 4afaa3ee804f31a3 1 +defs 4c031399d70c63d6 1 +defs 4c2c8773e6afbfc3 1 +defs 4c2caaf3d6ca8c78 1 +defs 4ca0617355962ae6 1 +defs 4ca9fa48f6567768 1 +defs 4cd5d6d5bc533efc 1 +defs 4d1f25b9f88ce40a 1 +defs 4dd0e56fa6b1cd26 1 +defs 4eb915efc09067e9 1 +defs 4f0a11cd12ebdd64 1 +defs 4f1eeefcc6361836 1 +defs 5016e2cec581b778 1 +defs 5098ceb6ba93f0f1 1 +defs 513a82879f6988a3 1 +defs 528f29df5a342dec 1 +defs 532d4763ee4f0d51 1 +defs 5349e49cedd311d3 1 +defs 539c00ef45a998b6 1 +defs 554719c9558ee90c 1 +defs 5567796a9df49bf1 1 +defs 5690a7e6f4b931fa 1 +defs 56d3eb4a00f6de55 3 +defs 56eccd261ee32406 1 +defs 57118e22b1dee7aa 1 +defs 5738d7b2ce7e1fb1 1 +defs 57aaa7dc54b71ee5 7 +defs 57cae2ed030dc221 1 +defs 5825699eb9713c9b 1 +defs 5844b083ab372a93 1 +defs 58b6201c2abb3c2d 1 +defs 58c2008305b0afe0 1 +defs 58ceb8247ac5a5e8 1 +defs 596a40ae7adf3b66 1 +defs 597bcfac2e1bfc60 1 +defs 59fafb2628d5e22c 1 +defs 5b7d514864a7fe74 1 +defs 5ba349d3f9dbe981 1 +defs 5ba413ec871b1a05 3 +defs 5bcbc43e0bfc1c04 1 +defs 5bef2751b97cdaf8 1 +defs 5c427223e4f7bd31 1 +defs 5c62bc4336a92380 10 +defs 5c976139ae63e37e 1 +defs 5cb29ea4b16ee5b4 1 +defs 5cff4150fa39e9ab 1 +defs 5d8dea77474eef46 1 +defs 5dc5ca231ce7af8c 1 +defs 5ddce9f50b0c275d 1 +defs 5e6955ad62fc35dd 1 +defs 5f4c2b1da49767c2 1 +defs 5faf45317652eb97 1 +defs 5fca1dab3eb84ff6 1 +defs 5fcec82716ef1539 1 +defs 60ae7b12db9b9c97 1 +defs 60fdd1aa679748b0 1 +defs 610775902254026b 1 +defs 61fe78aa31fda57a 1 +defs 62118ff9d5625689 1 +defs 62c4c7c808ddb517 1 +defs 63d5ec100a00a495 1 +defs 643e391062d5ad98 1 +defs 64cee7e94959c37e 1 +defs 65401e97a7b3dde3 1 +defs 6619eb15218ab6f4 1 +defs 662cc3aa77fb9b46 1 +defs 670c6a44b9bdada6 1 +defs 672566a3610ffd5f 1 +defs 67a27cf55608f473 1 +defs 681cdc3561eb7d64 1 +defs 689179ee148b90d7 1 +defs 68e893e8f3d71fe9 1 +defs 690e7d5a20a3fe48 1 +defs 696611d56fdfeda5 1 +defs 6a437d0c0e83cacb 1 +defs 6b5abc249db062e7 1 +defs 6ba0e82f39ba4f6a 1 +defs 6c08a8d51137d60d 1 +defs 6c0907eac176dd5f 1 +defs 6cbc12bcc60a4e8d 1 +defs 6ce6bd5b9c4a630f 1 +defs 6d661a0458d987d3 1 +defs 6d89a0375f525b9b 1 +defs 6e594189eafe0408 1 +defs 6e7d533058e61f70 1 +defs 6e8de06cc7d7c1b9 1 +defs 6e9f1d938c50aeaa 1 +defs 6ed1fd271b188d46 1 +defs 6f12044892549b6a 1 +defs 6f6b77ffc341ac63 1 +defs 6f92d0e9fea66324 1 +defs 701d98e1ee6bcead 1 +defs 703eae2147b6debd 1 +defs 709961d34ae00031 1 +defs 70acb27eb42344f9 1 +defs 70f36ffdb4452f92 1 +defs 718940229dd98516 1 +defs 71d2cb7f1d1a951f 1 +defs 72e0017a3e9b4ca1 1 +defs 72ef52b7bd594829 1 +defs 72f4fe268d6cb29a 1 +defs 735d655f4fb0fd03 1 +defs 737bc925aefcc6c5 1 +defs 7385fecc2a9f3960 1 +defs 73901c5b41eaae62 1 +defs 73e074eadb44c9f7 1 +defs 73e72ba081fc59e2 1 +defs 73f11932d9093d6f 1 +defs 7464bc75ffa4b7c6 5 +defs 74798dc894205762 1 +defs 752011234791fb28 1 +defs 755a08e70176b633 1 +defs 7594fbf007da4c74 2 +defs 75c8bef4f03819be 1 +defs 75eb9f6f072bdcb4 1 +defs 766d82bfd86dd118 1 +defs 76c1f41550b1b997 1 +defs 76dc0bb4769da62d 1 +defs 776b8b1eab8668f4 1 +defs 7791b74c1fceaf2a 1 +defs 77fbb3c024e536a6 1 +defs 7869708e768f836d 1 +defs 78af546e01ca37a8 1 +defs 795d777e64164e3c 1 +defs 79a9c21686684680 4 +defs 7a2991b69512efe4 1 +defs 7af16e2b108a072b 1 +defs 7b38b69469da3083 1 +defs 7bbbeff107c17bde 1 +defs 7c0c8d55f5c503d0 1 +defs 7cbebea3fdb2d0f2 1 +defs 7d74b2ed6c067b53 1 +defs 7e30b28a733bf314 1 +defs 7e6fd84a59cb93e2 1 +defs 7e865b52c71df10f 1 +defs 7f09d0dc03338350 1 +defs 7f0dfcb8e7044238 1 +defs 7f303a84eac3193d 1 +defs 7f4d61b6e5398157 1 +defs 80916eb377aa30c0 1 +defs 809f43336ad03a2a 1 +defs 80e0733dd1dd8967 1 +defs 8145a5f15c149225 1 +defs 81bdfa9c95a47411 1 +defs 81cb89a3777fa95b 1 +defs 823b34f6ccc08503 1 +defs 82789957ab0d60dd 1 +defs 8344f0e335b7777f 1 +defs 836124753f3d43ab 1 +defs 83617b448ae4f5b9 1 +defs 83bcd9fa7921e9d3 1 +defs 841f41fa7a532342 1 +defs 843711ddc2000a69 1 +defs 8444bfbbeb4b82aa 1 +defs 8448f217eadf0cb2 1 +defs 84f0a5079e308d96 1 +defs 85a1cf5cc0f384f6 1 +defs 85a803fc71028fe4 1 +defs 860c6bcc0adecb82 4 +defs 861e750aa9a0a707 1 +defs 86216d2ceafc4984 1 +defs 8634268abbb0b083 1 +defs 8642302fa250c337 1 +defs 86432dad9ce7d6cb 1 +defs 86a231892fd2185e 1 +defs 86a3f7bdf62de7a0 1 +defs 86bb4d97aa1153b2 1 +defs 86d9ce774ac0e9b2 1 +defs 873c2b8fb5cc9d7b 1 +defs 8751c283bf5503b0 1 +defs 882bbb80b4c33a2a 1 +defs 8834877c9b15df09 1 +defs 888095969ea9ab92 1 +defs 89776c50b7500989 1 +defs 89a3b60759c7a791 1 +defs 89f9967795227eb9 1 +defs 8a7b0fc3a462a5d6 1 +defs 8a917b2e79abf10b 1 +defs 8bfad7e73ac0394e 1 +defs 8ce48499c2224772 1 +defs 8d0b4c6c4e0b8287 1 +defs 8d5b6014948b0f22 1 +defs 8d74a3b2fe7eb966 7 +defs 8d9a330126fc06e2 1 +defs 8d9a862e3af62f77 1 +defs 8de5991c8f804dc9 1 +defs 8e8cf68def8d9e0e 1 +defs 8ed2c4409443dda9 1 +defs 8f9de2b50c6d0080 1 +defs 8feab80bf0370f32 1 +defs 8ff96a7fddf76be1 1 +defs 910bfa248b2227af 1 +defs 917b56d82c963db9 1 +defs 917beb804a058565 1 +defs 919157b8dd21ebc5 1 +defs 91dcc2e5054d5bab 1 +defs 925ba93869af880e 1 +defs 92938b5bcf3574b7 1 +defs 93323bfcca74ac92 1 +defs 939d426bd40f34bb 1 +defs 93b9d305982206ad 1 +defs 93bf0fd6213a7b84 1 +defs 9407348f6ee68f78 1 +defs 947bae210f6a7031 1 +defs 949ba1dba9a922ee 1 +defs 94aad1a9ad691252 1 +defs 954ed37fdd30474a 1 +defs 956291fcc7f26916 1 +defs 964723e6313d848e 1 +defs 966a40fe1711ccf0 1 +defs 96dfca7e24aaf25d 1 +defs 9777dc82c98426b8 1 +defs 977bf6fbb79350d3 1 +defs 979f53be1f68fa7b 1 +defs 97f4e74e9c4d232b 1 +defs 97fb9fe5299fca74 1 +defs 9854a9f84be0722e 2 +defs 989c038c6c9c954b 1 +defs 99054039062a6d56 1 +defs 9a08845a5a2b39d5 1 +defs 9aadf5d88db176de 1 +defs 9b00b22bd2dfcdea 1 +defs 9b15492f093e9680 1 +defs 9b675b24201a7db1 1 +defs 9c0e8638585adacf 1 +defs 9c2fdbb657785b23 1 +defs 9cf542d698798567 1 +defs 9d0250cf760a1cb1 3 +defs 9da180752e6d8e5c 1 +defs 9db9edf9dc3ccc47 1 +defs 9dfcef85a5056fc8 1 +defs 9e1070f26a7961f5 1 +defs 9ea88db302fadbad 1 +defs 9ef916a9ee01d457 1 +defs 9f4839d437dbdb32 1 +defs 9fe93961fce54ce4 1 +defs 9ff084684afeb19e 1 +defs a02c8cbee597d352 1 +defs a04d750c20c1a89a 1 +defs a077984a0c213988 1 +defs a0a05b5b3bae9287 1 +defs a0cb16a743107fec 1 +defs a0f0fea3368f2393 1 +defs a103963d54dbd311 1 +defs a17366facb51952d 1 +defs a188cfc59cab8daf 1 +defs a1b43eb494b1fcd0 1 +defs a2723c0c456ffc48 1 +defs a2750be13ac0ee82 1 +defs a2947b425b951a72 1 +defs a2c7a6bff645bd0b 2 +defs a2ea72ba4256d1ad 1 +defs a3135ee44b475bac 1 +defs a3c2bb108cce8ba0 1 +defs a3f0563f01c103d7 6 +defs a41d39c5795ca771 1 +defs a508a4b30b2f79a8 1 +defs a525edb704ff846a 1 +defs a5ae71540871bba8 1 +defs a604a99ead07e8b7 1 +defs a62599079371da08 2 +defs a649d84498c126fb 1 +defs a6575e6a24b93565 1 +defs a7d38a0353d337b6 1 +defs a9956363396fd78f 2 +defs aa7daecde0716211 1 +defs aa8df295f2993c33 1 +defs aafe6cbcf5a00afa 1 +defs ab7a128a7db81697 1 +defs ab8c28087d812326 1 +defs abb36acf86e05f44 1 +defs abfbbb2620e0ae26 1 +defs ac2dcd57d0e40382 1 +defs ac5f9599e49c6015 1 +defs ad36ead5dc5ed87e 1 +defs adb4262180fbbfa3 1 +defs aea683ac23a9c3c2 1 +defs aee6e643327d448c 1 +defs aee9bf52c500e4ed 1 +defs af029cbd84ef0056 1 +defs af2de214b51708e1 1 +defs b0176088079c92c2 1 +defs b02160df0862ef04 1 +defs b0a2e27b2dbbb205 1 +defs b0ccffd9ae0581de 1 +defs b13dd85e39366f9b 1 +defs b16c5ae4b8177443 1 +defs b1ca8c69bcb29f4c 1 +defs b25689d132f6a3aa 1 +defs b43807588b3d388c 1 +defs b550f7063b5e1658 1 +defs b55f7472956eb256 1 +defs b5ee57928f707a5b 1 +defs b5fb452f795be5ae 1 +defs b6774bf12793f446 1 +defs b70a9e2dff98d690 1 +defs b873090225df1db0 1 +defs b8c3995c4bdabe95 1 +defs b8e9148c718a83b2 1 +defs b926ca3a7fe53ba2 1 +defs b92a296420f44bb4 1 +defs b999d958d4795300 1 +defs b99d7ec7f2aaacc4 1 +defs ba063e007e4a0089 2 +defs ba0efed99d11207b 1 +defs bac2ec39302668c7 1 +defs bad8f1522ba80ebe 1 +defs bafd9e14f2744298 1 +defs bb14250915d7445f 1 +defs bb4bef0b64fd8d48 2 +defs bb70288b5c1a5ab3 11 +defs bb774894bae68dfb 1 +defs bb7a2eb4b3663ef8 1 +defs bba7ed99af8f5b54 1 +defs bbd0138ed7a0d350 1 +defs bbee458ed08455f3 1 +defs bc12021c4f16c7b1 1 +defs bc2a33a973f536c4 1 +defs bcb1ccbb00ea585f 1 +defs bcdea3156f45a122 1 +defs bd028205e65b668b 1 +defs bd1086a51723f0a9 1 +defs bd409f1d90d1ec7e 1 +defs bd99db1ff250e606 1 +defs bda35bf5d19afe35 1 +defs bde9492df89e79bb 1 +defs bdfa2256a1243766 1 +defs be1c296b4103541a 1 +defs be5051b47f524a38 1 +defs be608bd98b316613 1 +defs be6f1003b9147c1e 1 +defs be9f5fbfbcf49f3b 1 +defs beab83a2a041144e 1 +defs bfc39e13eac109b5 1 +defs bfe88aff0d1cf1ac 1 +defs c04451c38eea2f3d 1 +defs c0f637cf0b098e2e 1 +defs c12ea4bb6c4307ad 1 +defs c141ab01fc2a3cd6 1 +defs c1b86e1466d682c2 1 +defs c1d80e01a94fb0d5 1 +defs c2b7d06e06237be2 1 +defs c372db7ad947ef02 1 +defs c37c3f7fe77cffe5 1 +defs c5ae5fa47a4cf64f 1 +defs c5b0ee41b05acfb2 1 +defs c60ec2d90df2762b 1 +defs c6a6785521523225 1 +defs c77b05daa4ff56ae 1 +defs c7a2188f77229408 1 +defs c83cc8417e632ce2 1 +defs c853f415c89a1d6a 1 +defs c85f0d56aa6253b5 1 +defs c8c1c712735b0ccc 1 +defs c90f6570abc63716 1 +defs c9417f90820411a2 1 +defs c972eb6e158540ea 1 +defs c99075537de59d30 1 +defs ca51003e7d5cf837 1 +defs ca612ead4b39d244 1 +defs cb0370a917abe0df 1 +defs cb488f245b2795da 1 +defs cba3c34ae22ec49b 1 +defs cbaf49d9aa662ec8 1 +defs cc256dd80b037384 1 +defs cc329731922ce601 1 +defs cc383f93cc4a1177 1 +defs cc527f122cca2eee 1 +defs cc71575aafbcac28 5 +defs cd44a949d2c08a9d 1 +defs cd514d3bf7b25bba 1 +defs cdbea33e56e46c5a 1 +defs ce20725a9f915d01 1 +defs ce275778f4d4977d 1 +defs ce288f34f8e6e4af 1 +defs cf024f76b08afd38 1 +defs cfdc0c8bc7aaaf12 1 +defs d05a659360ba6f3c 1 +defs d0dc0130bbaffff0 1 +defs d1d5db6a15028a9b 1 +defs d2085fbebccdffda 1 +defs d25a3ce67a83b39b 1 +defs d27009c85a85fc05 1 +defs d2b8b85442a71925 1 +defs d2d13b315fa7cda4 1 +defs d3c8d6b28ec1e96c 1 +defs d3f0565c6040da91 1 +defs d3fcd81e343f10ef 6 +defs d4885e4f3e20b47b 1 +defs d491b0153cfde2d8 1 +defs d4d8059fa01f1e3c 1 +defs d4dce2d2b3a5855d 1 +defs d4e70b54741103c0 1 +defs d50d180e3f8249f7 1 +defs d51295ab04b0d39c 1 +defs d560adb4a23e2cff 1 +defs d5b13308dc43b558 1 +defs d5bb33cd7f8fba6b 1 +defs d65071a443bfd4aa 1 +defs d72258d6542408c2 1 +defs d78a9a1b73a37bc2 1 +defs d7bdfd13e38c53eb 3 +defs d83135f37d441e8a 1 +defs d85fa2a1d24a1d9f 1 +defs d864f94da21ec08c 1 +defs d8bec32aecbb5851 1 +defs d8cb3bc23bf7e3c7 2 +defs d8e1d53e3c403ae2 1 +defs d900a7d57a7a6e51 1 +defs d94841dd9e08985b 1 +defs d9a72ef3652ab4bd 1 +defs d9b34ca129cedb0a 1 +defs da2f7f459c5ddf7f 1 +defs da42d957244e0f81 1 +defs da62006c9ee9330e 1 +defs da950ddf8dfe0436 1 +defs dafd603c2f091213 1 +defs db6e9f0bb941444e 1 +defs db7e32c39b011150 1 +defs dbee05807e339e8d 1 +defs dbf55aba3ae608cc 1 +defs dc57c1548a19f6ef 1 +defs dcd3b9c5600c74ac 1 +defs dce93392eeee89dd 1 +defs dd55181effa0995b 1 +defs dd7f4ba2505ff33c 1 +defs ddf05a9fda55b0c4 1 +defs de0c720a86e29e55 1 +defs de35fac2281b4eea 1 +defs de6229cf181cd846 1 +defs de6f49bdb8c48e7b 1 +defs de9df27d11fd6d43 1 +defs df0e0f92f2092438 1 +defs df22835a99ccf5bc 1 +defs e0d588512343fa21 1 +defs e10010a21b629e66 1 +defs e146ee1a4a342aac 1 +defs e1b4c1103fc2ed5b 1 +defs e23c2226eb4158af 1 +defs e2699fd6a3fa1328 1 +defs e2fe3265394778ce 1 +defs e300b1faf6e1d012 1 +defs e36cf8c9d9f1cd89 1 +defs e378ef5afbb3e408 1 +defs e3a90ae9514813f6 1 +defs e3ad8a7c6e96972c 1 +defs e3d2d3a21b154af6 1 +defs e40b81d45cf6363d 1 +defs e45f0d4802d2848a 1 +defs e46ab7a2d342d4e1 1 +defs e475358369fb36cd 1 +defs e49178f48c0574fa 1 +defs e4c073933f1e47b6 1 +defs e51a8d8ddd82a50d 1 +defs e53f1181d710f13f 1 +defs e57a61300c415efd 1 +defs e5eab62fa164f5df 1 +defs e6378805498ae207 1 +defs e65813db8d9b2bc6 6 +defs e672f75e6728a41b 1 +defs e6e4049fbb37155b 1 +defs e7afc20b0204158a 1 +defs e81adba88c94d542 1 +defs e81be21ddfb4b2ca 1 +defs e83f724256ae1c03 1 +defs e8bf877e84fd93c8 1 +defs e963d0798b6c2416 1 +defs e98c2bc92448636c 1 +defs ea67c8fe6f523c94 2 +defs eaaca8031d102a8a 1 +defs eabdb1c11c0b7344 1 +defs eaf3758011d7fd20 1 +defs eb21547e8fc5798d 1 +defs eb59730bea76712d 1 +defs ebd54b9559a8d1dc 1 +defs ebe6c7d83a7b0b88 1 +defs ec0a3b87d49de600 1 +defs ec23757266779a3d 1 +defs ec238b174e9bc2c2 1 +defs ec571d26837759c3 1 +defs ed0f421581be922c 1 +defs ed51829342734599 1 +defs ed5af324fbb452ce 1 +defs edc3a81a5285678e 1 +defs ee8b47838e145fcc 1 +defs ee9fc2ad102cff33 1 +defs ef0b1c5fa0b90ab9 1 +defs ef1d53b8c44bbb67 1 +defs ef29cc93418187ad 1 +defs efb4a906769bc50a 1 +defs f028f44bfc9220df 1 +defs f03f21e4f1638e0f 1 +defs f103a8f1cc586bbe 1 +defs f148d5a58bdea0ef 1 +defs f189dcfe4fda591e 1 +defs f197817f969bf7c7 1 +defs f1e3e5d2694a3263 1 +defs f2b1cab4ba10688d 1 +defs f2e357d4e331608a 6 +defs f3522319ec73e6b5 1 +defs f375a8b30995f676 1 +defs f38296f6730d0380 1 +defs f38d7d97db1cc3a5 1 +defs f4af891e3663df17 1 +defs f5da182a28999848 1 +defs f6499fa9b56dd88d 1 +defs f64f3b1838d23608 1 +defs f69b15b433a0ebb1 1 +defs f6cd3e68decce213 6 +defs f6d09aaa879795f5 1 +defs f740404a18ee9317 1 +defs f83dbf2f26b3d9d6 1 +defs f85e3c71bf15f382 1 +defs f8ff1b999d5139e6 1 +defs f90db1a16f62ee03 1 +defs f953ae6dc1284559 1 +defs f9710e2cab88cd14 1 +defs f9bbc08faef1cd9a 1 +defs f9f84d21d21eb570 1 +defs fa47b525839d3e9b 1 +defs fa6e14ca0bb22b76 1 +defs fb57d0b51a01ea3d 1 +defs fc41f933bfe74d4c 1 +defs fc7f155243219c03 1 +defs fd19ef11039692b7 1 +defs fd619dcdcef1e43e 1 +defs fde1a69149bde1e8 1 +defs fe09c2eea1d66805 1 +defs fe21950435627c9a 1 +defs fe7c1a71bbacd6c7 1 +defs fedbe7b3fffd467c 1 +defs fef21dd5727bbb51 2 +defs ffd1fc17b731e639 1 +defs ffec8b3317b77318 1 +bodyq 4b733c4d83b07 5cdd860fe1d959d8 +bodyq 23f7b60bac46e6 3c31de1987ee1b48 +bodyq 3db89571c08346 aac78d411b7b35c5 +bodyq 1b18ff4bbe15dc8 60f8457f2c02a5c1 +bodyq 2340a4830b3aea6 311a42eeeb6ac8f5 +bodyq 24f99e084d8eac7 7df595886fd504c7 +bodyq 25992276d82d57d fef0d8d49671a7a0 +bodyq 28c515cc9cf15ea 917cd828b3c43f64 +bodyq 292e78225cb9e42 3f942e3ce1b17d2a +bodyq 33b850c282de191 50ab2a44e38cc004 +bodyq 3d91ed95503b07c 2b74b742d1646005 +bodyq 404b9670f3a703b 1e7b0aae4ee21af0 +bodyq 43c9282165db6d5 2445ccb8b8ad423a +bodyq 567a6c2fb1b14e3 5289edb7117e26c +bodyq 60be78b537264df 271df45926645880 +bodyq 6434baca4e727a3 a55ad4712a46c3ba +bodyq 6593a5202605144 d801c4f31e0a1e81 +bodyq 6fa75d99a22b8f8 f0c6964e873bc507 +bodyq 75ca46ce0ff4da0 710f6454b85b4051 +bodyq 7b978a659fba30c d7e1b4ea2d513c50 +bodyq 7cab3f5048545bb b578ce30208dca3 +bodyq 7ecd582e890ea31 af183e55ba7b481 +bodyq 827933c0d01af9f f3d918bf5ba1fe31 +bodyq 8528c5691f65b73 c4ab699c2c4f1d2c +bodyq 85e190a60167712 c2c7aad7316f6dab +bodyq 8819a45d3577c0d 2e565126af9d5562 +bodyq 8cfc26a65c57400 b156fa830a07acc4 +bodyq 90af57aa911e8c8 ddde2bdba7dbd8b7 +bodyq 921b5208a7d7443 f5d9500b277376e7 +bodyq 9290bff224c908f f0df3ed445e3fb40 +bodyq 96de467da885077 a4ae7840ab86bd05 +bodyq 9ca30156b74ccee 323f0710ce55ee69 +bodyq 9d3f23b15ca9b60 87807d8402c5e5a4 +bodyq a366a6cfe2d6884 71d2ad0b26067db3 +bodyq b42253871667dfe bff0a44e5790713c +bodyq b50f1f49cd3855e dfb84d91a142dd65 +bodyq b516b97cb14b102 8e3044a1464b4a8d +bodyq babbb34b92b7a3c a52a59144a25ef74 +bodyq baff7355e29d862 7596c5d82d4c72ea +bodyq c17f5e8b96c68e7 c7cd08c68de9f149 +bodyq c7aa4beae78784c dc7e73b76dd43cb8 +bodyq cc857a81514720b 994e1b3fb7ffcef2 +bodyq ce934e313754b1f 5585e7b1759178eb +bodyq d2cc7984bc1c193 117b8824a3e51096 +bodyq d2f8bf6bd734dbe 18522843175d03c +bodyq db814bc584f0227 15398d5935fbc2fe +bodyq dfda1f29948b476 32fc182f690825fc +bodyq e287345a1da751d 9b5da55ea931131c +bodyq e76fa62254196bb e1841a353a9e9e19 +bodyq 10ab7d01d10ccbe2 a76992bb4bf2d659 +bodyq 10d0776f5dc84500 2fbe80535e12cdab +bodyq 1102ee13c3f287bc fbdf448346e85123 +bodyq 119e3a9de1579038 7d792bd0c665e201 +bodyq 11b9cd4c327155e8 9f3109670ba07ebf +bodyq 11cdabaaa36de566 ccbe12c1d311a504 +bodyq 11e1842b0846e3c2 21bb72e04e85be06 +bodyq 121769c14e306576 8361bc935b9896a7 +bodyq 123ef12c5471047c 499f7dceb57c483d +bodyq 12d781289b1f6537 5060c5cc454c7e5d +bodyq 130120439ca7e74e bf84508335fa57c3 +bodyq 13e98b0aac2ac45a 3f86717e44d7f473 +bodyq 1439d5906edb93a5 50a0cceede9be863 +bodyq 153e1fedb7354bc2 73052eb9ba43387f +bodyq 1544b3bf28bbba4d 728bd07605778eca +bodyq 15cd8d11c115e37d 9cf1dfb713e6d02f +bodyq 16b12a8a9877f141 2f10031d850957a7 +bodyq 1706f0f1d4b7d5aa d137ca89da9a3f55 +bodyq 1784741bfa7af1fd 3c6ed17f92cc1341 +bodyq 190843bf16d81bd4 2f49f77348862d9b +bodyq 1a3d08994f90d6f9 3fcfff179fa54fcd +bodyq 1a8c427d3dec251d a8a4dac5bb8dd6fe +bodyq 1a99603a2e88d654 17b75824e5f00826 +bodyq 1aefd4b805333268 c4afa59efcb1f395 +bodyq 1c1f924943890c8e 899a9f7e8bf79653 +bodyq 1c470b2e35f931bf 89a5e9fa9177f496 +bodyq 1c6db503caf6a152 5cf5583e80828ab6 +bodyq 1cf5097d9b16f9b0 d640b0fc7fda97f6 +bodyq 1d00916cd2964c35 a49e2079b685883 +bodyq 1d08599882d7cfe7 89b5bb66b3fd2682 +bodyq 1d1c8eb3ae5168fe d0eda29d0e520605 +bodyq 1d2fa22ed4ee9ff2 7e3fff70eb10a1f6 +bodyq 1d5a11c7d1edeb11 f30730e298901821 +bodyq 1db269061a925799 31756fcbecaf509e +bodyq 1e539f3e54012138 7de09b553ea67ff7 +bodyq 1e6ca46d5635c401 345bdfd44b663b4d +bodyq 1e8a64d9423479a1 3a9778532925fb63 +bodyq 1ec2fb826c707c29 26a516657ec9bcc9 +bodyq 1f2b736eb17cf738 f5564280899a6f61 +bodyq 1fc0fedd54c8e75b 8ac403918b872434 +bodyq 20305a32e94c6e20 e1a1444eb83bb2eb +bodyq 209b66ed45486485 a63541e9e27f9deb +bodyq 215d259a676f1c6b 3e83a61bc0444dc7 +bodyq 21b214aba3517cf9 d58351f2b391036d +bodyq 21dbd83072a7a2b0 83a8c0d76de6463f +bodyq 220a016c123269d2 5ef4ada9bd83c6bd +bodyq 22f146b9d55161f5 d0eb8efda0dbe035 +bodyq 233ed227bd9ad4ec bdec7d0aaf1e9b5d +bodyq 23a4ccb986e9c241 7ea82a457d9977f +bodyq 23b429d4f8393438 703935f3f02b9455 +bodyq 242bbb0e5b9ec108 2d98eb7c090edf96 +bodyq 2449a169abfc5709 e84aee6958e83a20 +bodyq 248d8aea5119d112 a72d39996bd62f96 +bodyq 24d563a0bd0284ee 456166207f3f0c87 +bodyq 24d61732a772f6ce 9ee9e1c6f00ad96a +bodyq 258ed6b3635d219f 14d84606a485fe48 +bodyq 25b33c65ec17c605 e4b28d11e75ea78c +bodyq 265eeb107f4f7037 4a79c6185715641d +bodyq 26600eca83f91cdc 966bcdb9c43e4477 +bodyq 2694ccc9cd9c2e4a 6435514dce6b98e +bodyq 26f2906b4561e5ed f2ab449df06b0955 +bodyq 270e54f53f0dec25 32011aed649ad0db +bodyq 2765d6c249664ab6 a9edf275a235502b +bodyq 27af60e478606323 1268a7e41545c8bd +bodyq 27f05b6efba965c8 41fb45d067fed163 +bodyq 27fcbd42c188ec3e 683ced5b244d591c +bodyq 28a6e8c239e04047 24026ce00f2a0582 +bodyq 2909b7f6c9f5d18d 46155c830312354 +bodyq 29167175f22af761 a467954cd8b8dff6 +bodyq 297a4160638db8fd 5ea8ce8364611139 +bodyq 29b64a559ea4ffff ba318b649c5830a1 +bodyq 29d1bc2a36afc76d 4162de9826608a88 +bodyq 29f5a11bbf71babc cf5b805414866662 +bodyq 2a14568b12903218 470b36d2c6bcd94f +bodyq 2a69b5e495fafcb6 89e33d5d32caa32b +bodyq 2a9e43e7514907ba 5370e6c4476d90a2 +bodyq 2ae26defd322202f 8e848607c3670d95 +bodyq 2afac393e3eb18c6 65959292b5f22817 +bodyq 2b3b72e3b3627a3a 39cdb42601687b7 +bodyq 2b7f42fc1c74594d 15f2b5096819bd98 +bodyq 2c77117a0e84cfe1 8563953f20ae0603 +bodyq 2d4f0807a4377e66 6728a037e31a5125 +bodyq 2d6194ec9f73238e c15553cfc2dd652 +bodyq 2d8466f6954e126e c460f47ca084bcc3 +bodyq 2d96edc3799baa0e 4596e37042f545a6 +bodyq 2dd16cc3aa6f6523 6dec11d9dd92b39c +bodyq 2e0b841baad1468e 9023ca64de5bdff1 +bodyq 2e395ab5adfaffe4 47984af5f567bb69 +bodyq 2e8b4e73d01cda1d 522866b68ad3d30f +bodyq 2e8f98f9891a8cba e9260b56ecc82fd0 +bodyq 2ec94c46492bdf4e aac78d411b7b35c5 +bodyq 2ee347526cbe8582 649b38e137c385e2 +bodyq 2f0b87d442d670ab 38e9d920824806dd +bodyq 2f379cdbd5b7baad ab92a63912a33c7b +bodyq 2f48393189526db5 aac78d411b7b35c5 +bodyq 2f750d28b00ff12a ae802a7709a83c9 +bodyq 2f8af7800010622b e1559d850c03c177 +bodyq 306ff4fa969c2b5e 31b27b4dc30a1f1c +bodyq 30823f7b3a383b0b 800b92cd9bfebc47 +bodyq 30b49386bfe531be bab25024a158988c +bodyq 30b5d92358dcd253 51534e9abafdcbbb +bodyq 30fdfead3bc13677 a57f4d03a84acf34 +bodyq 323f04fd5a7a671a 32fa6cbdaeba5bb +bodyq 324c715b3ef59fb5 be6bf7169b4b6e64 +bodyq 334ac2095aeeffb5 f3da07f8904d145d +bodyq 3440f8d589cae291 338885d0c72626ee +bodyq 3455adbedae84514 d05d5b99d9971cfa +bodyq 345a2fdfacb84efa 297090a838f723d6 +bodyq 34a9957d1f0f0b52 b79814d85f89bb71 +bodyq 3599e84ad64ea3a0 70225492dd45ad33 +bodyq 35d9e976f25eecfb 3bcab547b795c6b6 +bodyq 3677e09eb4196cd4 da5ba8217258f632 +bodyq 36aff0a235db6e64 b80835edb00e811b +bodyq 36eb5585be49ea31 70d6cc7866b387a8 +bodyq 374dc0d9439e660e 6143da5ea9061888 +bodyq 378070cac6c29d8a 27ba149d6d1bd77 +bodyq 37d1819c814b6e48 65225f9bf4bb04b5 +bodyq 37e6c1a9572eed09 675285a4e8773332 +bodyq 37f14128572d051e 7b80a5c4c8ad901f +bodyq 37fde5021a0194e9 937d41ca3177ffa5 +bodyq 3817c48d6d009d40 187dc6dea1fe414a +bodyq 3844be8089f457b2 6100b5c671eeaa88 +bodyq 386a103486cff027 db689a00cb48a6fe +bodyq 388245783c0938fa 1ad95d569373c205 +bodyq 38b9413e0cbbaac0 2076c40667f2dcb2 +bodyq 38d2574e03270d77 501f067aaacae63a +bodyq 39aa49c948c80691 71920b817b43c88 +bodyq 39dc70474f1885ae e4da3c53482b7825 +bodyq 39e896bec2ab77c9 bfc00868d37b1a7a +bodyq 3a01c0eb5dcd7613 32df765656d821e8 +bodyq 3ac893ff638f771b 9e2b38bf020e5c12 +bodyq 3ad308b61b8f874e 41a20d845686e175 +bodyq 3b670e3e6581709f b46ccc3e2a4b4a69 +bodyq 3b6d5a0ea9a511e9 4637a6488e3265a9 +bodyq 3bb6aca50f06a606 6cc5dbc129003b8d +bodyq 3c3d72419cba9309 92c1735ef1f52274 +bodyq 3ce01651a41aafb6 f9f45dc01eb1f11e +bodyq 3d13afc5e29e371a 9c6e280c82e3af66 +bodyq 3d8282d048efcc77 dc3e4f4c26b12355 +bodyq 3d903cc2ced5fe66 5db5dc7cba6aa2fe +bodyq 3e185994d574529f e03a648313548e79 +bodyq 3eede00189a7a15f c771f49d8c847068 +bodyq 3f6dfe71df011044 61ed51476151bd01 +bodyq 3fd29f21cbad3628 d893f2bfaa3dc535 +bodyq 3fd493d0cfa3199c c2fa73a63608824f +bodyq 403c374008b3781f f0cb39548fc99bc2 +bodyq 4051d8f6c2af839f 917cd828b3c43f64 +bodyq 410fa58f10ab2614 e44d9b668d36e987 +bodyq 412954cd14f67658 2d1aff0591129c6c +bodyq 412e3e27d4215e06 68931c7c417c0d7a +bodyq 41673bb8d8375d55 8768e9d531d5efb9 +bodyq 418b8560d8501615 b4dd29e308a7d1be +bodyq 41d3ef4759cf2df9 9128b136fc30f752 +bodyq 41d5fcfae22584c4 2f5d71d250baa424 +bodyq 420f8a970c0221f5 2791f27129d860c3 +bodyq 42b8290f3fed3450 bb842912208768f6 +bodyq 42c814d98094dbb7 a88d706284975039 +bodyq 430cba9d0ec3ab56 7573dce28ef6814a +bodyq 437e4d914b709dbe 1724085a67f7026 +bodyq 4425f307731f789f 8cdfb3b47f598a9b +bodyq 447dd766bab76e3d eb0e1cdc43e74a90 +bodyq 44c0fb3e0547cf06 4116dfbf555226fe +bodyq 44e4a85260ed66f3 2502e225a1d53153 +bodyq 4581af238c881954 1a99b505387dc817 +bodyq 45df799f4c412c1e 12938426f97540d4 +bodyq 467a56bed6f21356 43855e0909363ca1 +bodyq 468e7a55aa64e481 ef93ed1246b3f68c +bodyq 468f6db3ee1d572b f468f09de6bd0c0c +bodyq 46fbd074054bea23 bed1604bea4967f +bodyq 470d78a9487c1c79 90606432f1325a66 +bodyq 472324ee9732f077 c6bdb5d353a333f0 +bodyq 473790676e54eb23 7d0f9ec80191c902 +bodyq 479d7c2ae4e70e80 26a278e6473ef674 +bodyq 47f97c0fb37c8568 706d7d9dfb2870c +bodyq 47fa33217335c644 779d668ac7a7a5db +bodyq 482cd3901a33f7da 50eccd7b109f6729 +bodyq 4849325cb38239ae 6f4767d5d47522b3 +bodyq 48eb495d1a981e3b de76b2e95537b2bd +bodyq 492eb95e1d2c9566 d7cd7be5ff933c36 +bodyq 49ce64a0c7a6ba56 c212bb96ec6a82aa +bodyq 4a5b1dd8e915b277 e4f32eacaa1b9be4 +bodyq 4ad0bfd06eca3b10 c5377c77d7a7f13e +bodyq 4afaa3ee804f31a3 31f69cf40221d4e8 +bodyq 4c031399d70c63d6 d0aa5e83044dc14a +bodyq 4c2c8773e6afbfc3 1633367c1eb6c8d3 +bodyq 4c2caaf3d6ca8c78 9ba88a311bc8be0 +bodyq 4ca0617355962ae6 c2848631ebbc0467 +bodyq 4ca9fa48f6567768 63fe756a19feffe5 +bodyq 4cd5d6d5bc533efc b9e323d8a4c71af2 +bodyq 4d1f25b9f88ce40a 1d0b07b700b6014c +bodyq 4dd0e56fa6b1cd26 bd79d33d4e4fa330 +bodyq 4eb915efc09067e9 19903e440b3bf149 +bodyq 4f0a11cd12ebdd64 b3f1a9042115d03d +bodyq 4f1eeefcc6361836 72eac6a60284e2e3 +bodyq 5016e2cec581b778 83cc9825cc75de2f +bodyq 5098ceb6ba93f0f1 dff740f835a98e37 +bodyq 513a82879f6988a3 81e8467ea315ff23 +bodyq 528f29df5a342dec 1263193175767e5e +bodyq 532d4763ee4f0d51 af25153782ad1954 +bodyq 5349e49cedd311d3 ebd582bbe47345c +bodyq 539c00ef45a998b6 af3df40e7dc92bfc +bodyq 554719c9558ee90c 16bbb355325a0a22 +bodyq 5567796a9df49bf1 ddeafb2ae3595439 +bodyq 5690a7e6f4b931fa 8a232470f0c66b77 +bodyq 56d3eb4a00f6de55 ed7ca5fd3b378fc +bodyq 56eccd261ee32406 9beeb2b3257cdc86 +bodyq 57118e22b1dee7aa 22c9973aeaf6de4f +bodyq 5738d7b2ce7e1fb1 89f61807611d7d86 +bodyq 57aaa7dc54b71ee5 c3f9ea0b730036f +bodyq 57cae2ed030dc221 33150159646c4c78 +bodyq 5825699eb9713c9b 336ec4ed3d29989f +bodyq 5844b083ab372a93 6d8c18bc2bbc0386 +bodyq 58b6201c2abb3c2d 878243e107ee2bf7 +bodyq 58c2008305b0afe0 3543ed12bfa62cad +bodyq 58ceb8247ac5a5e8 800763fcbec442b0 +bodyq 596a40ae7adf3b66 32ab6f006457fc13 +bodyq 59fafb2628d5e22c 16c47874a3dd6b79 +bodyq 5b7d514864a7fe74 7f68f01631a19566 +bodyq 5ba349d3f9dbe981 6cbb2fd1ce89e3cd +bodyq 5ba413ec871b1a05 4f3af5e026448bdb +bodyq 5bcbc43e0bfc1c04 18b75bb64c5f928 +bodyq 5bef2751b97cdaf8 c109f8b35b0fbcd +bodyq 5c427223e4f7bd31 1b97a6bebf242f9 +bodyq 5c62bc4336a92380 db39c6419e9acecd +bodyq 5c976139ae63e37e 76a624305beefe87 +bodyq 5cb29ea4b16ee5b4 7dd597406c69befe +bodyq 5cff4150fa39e9ab 2610d04dad2aa676 +bodyq 5d8dea77474eef46 8d0df400459bd4ed +bodyq 5dc5ca231ce7af8c 20391c8729a0f6a +bodyq 5ddce9f50b0c275d fba270861f420176 +bodyq 5e6955ad62fc35dd dbfe80cd6dc05de8 +bodyq 5f4c2b1da49767c2 4699f25167177150 +bodyq 5faf45317652eb97 329ad61906590781 +bodyq 5fca1dab3eb84ff6 836e0b7cbab93a56 +bodyq 5fcec82716ef1539 3f4dd5e84566ff1f +bodyq 60ae7b12db9b9c97 69969c6ee32e941e +bodyq 60fdd1aa679748b0 f478783767b97e09 +bodyq 610775902254026b cfdee487b0b76fc8 +bodyq 61fe78aa31fda57a 2bd387584a2aec28 +bodyq 62118ff9d5625689 ad2fbd53a555f99f +bodyq 62c4c7c808ddb517 ea2923f358e91ee2 +bodyq 63d5ec100a00a495 41c642e8892413bb +bodyq 643e391062d5ad98 4325e573c6243c1b +bodyq 64cee7e94959c37e 1f2a8e85dd46d828 +bodyq 65401e97a7b3dde3 c081e25dd11bb65a +bodyq 6619eb15218ab6f4 4107455b5a8baf17 +bodyq 662cc3aa77fb9b46 917cd828b3c43f64 +bodyq 670c6a44b9bdada6 185621d5162a0a04 +bodyq 672566a3610ffd5f 5cc579e82a7f16a7 +bodyq 67a27cf55608f473 1ecb361b0e591ad +bodyq 681cdc3561eb7d64 e615f34b4c80c8f0 +bodyq 689179ee148b90d7 ff7e0ed3ecde51c2 +bodyq 68e893e8f3d71fe9 e7243d438ee3e270 +bodyq 690e7d5a20a3fe48 878d80d3c12aa14e +bodyq 696611d56fdfeda5 805847c70c9e29b3 +bodyq 6a437d0c0e83cacb 8dcca37ef21262bf +bodyq 6b5abc249db062e7 62f4dfa7aff5b752 +bodyq 6ba0e82f39ba4f6a e2d13584360a50b5 +bodyq 6c08a8d51137d60d 7bee874358eec6c3 +bodyq 6c0907eac176dd5f bd85625be7ebd9c2 +bodyq 6cbc12bcc60a4e8d f6f112697eb37346 +bodyq 6ce6bd5b9c4a630f b513848319aa2794 +bodyq 6d661a0458d987d3 e5e6b0b700aa9518 +bodyq 6d89a0375f525b9b b8e2bc077aefb99b +bodyq 6e594189eafe0408 d4755f9699b7db44 +bodyq 6e7d533058e61f70 5607b49c4370064f +bodyq 6e8de06cc7d7c1b9 70be49b1c26acb4d +bodyq 6e9f1d938c50aeaa 6a1df4e27232a202 +bodyq 6ed1fd271b188d46 e794ccb5ba94c20e +bodyq 6f12044892549b6a 2deb1473fd865da4 +bodyq 6f6b77ffc341ac63 78019fc498811754 +bodyq 6f92d0e9fea66324 2946caed06f92762 +bodyq 701d98e1ee6bcead 9f4a6a7353dded3b +bodyq 703eae2147b6debd 39b2b1ce19c3fa +bodyq 709961d34ae00031 5e55252e01f7e52b +bodyq 70acb27eb42344f9 995fa552ac2b8555 +bodyq 70f36ffdb4452f92 26b23111d6bf3b4b +bodyq 71d2cb7f1d1a951f db4717476e9724f4 +bodyq 72e0017a3e9b4ca1 1fd0c7b76d9b4a72 +bodyq 72ef52b7bd594829 9579b011243e2642 +bodyq 72f4fe268d6cb29a aa907b2d7632f5c9 +bodyq 735d655f4fb0fd03 840fb231a8823a42 +bodyq 737bc925aefcc6c5 267cad33454fad7d +bodyq 7385fecc2a9f3960 6e093cd3b8e9acc5 +bodyq 73901c5b41eaae62 c11d641a48be99f +bodyq 73e074eadb44c9f7 d17f1be3474d5e3 +bodyq 73e72ba081fc59e2 2bde216808ac5d4e +bodyq 73f11932d9093d6f b8096d394ffdbfef +bodyq 7464bc75ffa4b7c6 b875dc31038fbe05 +bodyq 74798dc894205762 42c670e5f877cda3 +bodyq 752011234791fb28 61c5dbd4f693ca25 +bodyq 755a08e70176b633 b986f667bd373624 +bodyq 7594fbf007da4c74 c2023d9c6ae850a1 +bodyq 75c8bef4f03819be c92b3233c579741c +bodyq 75eb9f6f072bdcb4 94c59a5ae183439c +bodyq 766d82bfd86dd118 5d5d2ab54a665616 +bodyq 76c1f41550b1b997 7e4d71a1d735b549 +bodyq 76dc0bb4769da62d 7382197bc6b850df +bodyq 776b8b1eab8668f4 a9dacaed7f9836f5 +bodyq 7791b74c1fceaf2a 8ca27da9f44e404c +bodyq 77fbb3c024e536a6 81f438d4382e39b1 +bodyq 7869708e768f836d 77322d9a8e158d67 +bodyq 78af546e01ca37a8 b057c673bdb716ca +bodyq 795d777e64164e3c 7cef4bf676adbe38 +bodyq 79a9c21686684680 31d18c3f123d7e78 +bodyq 7a2991b69512efe4 9302a7bdd0e4f58e +bodyq 7af16e2b108a072b a467954cd8b8dff6 +bodyq 7b38b69469da3083 b8935d270da807ec +bodyq 7bbbeff107c17bde 8585af20ff59e053 +bodyq 7c0c8d55f5c503d0 498de1c26ac3ea0b +bodyq 7cbebea3fdb2d0f2 4ab58c94ef819114 +bodyq 7d74b2ed6c067b53 3f5d22c8d4c6d91f +bodyq 7e30b28a733bf314 e0ebcb608d673656 +bodyq 7e6fd84a59cb93e2 917cd828b3c43f64 +bodyq 7e865b52c71df10f 48ae1b9a7c852f9c +bodyq 7f09d0dc03338350 221f85e0a465d206 +bodyq 7f0dfcb8e7044238 e5f3974d66658572 +bodyq 7f303a84eac3193d db4c61d57608bf52 +bodyq 7f4d61b6e5398157 427b2f95268c36f8 +bodyq 80916eb377aa30c0 c31dfaf4ed946915 +bodyq 809f43336ad03a2a 27aa21e390cd6d55 +bodyq 80e0733dd1dd8967 b4b0e0342f58d0a5 +bodyq 8145a5f15c149225 9934cdae0b2dad7c +bodyq 81bdfa9c95a47411 522ad58eafef3d7c +bodyq 81cb89a3777fa95b d8ad3d67079abce1 +bodyq 823b34f6ccc08503 d81f382c504b024 +bodyq 82789957ab0d60dd db35aab986e16fba +bodyq 8344f0e335b7777f d04727026b89a374 +bodyq 836124753f3d43ab d4084782f61af10f +bodyq 83617b448ae4f5b9 f66bf3a3e30e164a +bodyq 83bcd9fa7921e9d3 fdd01fee28d05d65 +bodyq 841f41fa7a532342 a210db71a59c108b +bodyq 843711ddc2000a69 a190fbb6087a0638 +bodyq 8444bfbbeb4b82aa 8547f02cd45c440a +bodyq 8448f217eadf0cb2 293540c5be6b2437 +bodyq 84f0a5079e308d96 e741cf0875063b58 +bodyq 85a1cf5cc0f384f6 80db6f6c492a6533 +bodyq 85a803fc71028fe4 8fb8c4cce32ed759 +bodyq 860c6bcc0adecb82 948fab881c417361 +bodyq 861e750aa9a0a707 6640b5e3cc4aaffe +bodyq 86216d2ceafc4984 aed1a9561cd86f28 +bodyq 8634268abbb0b083 317fe4f113f6f1b +bodyq 8642302fa250c337 4e34f2dc36d293bf +bodyq 86432dad9ce7d6cb f7cb7b80fd75554d +bodyq 86a231892fd2185e 4ef8ba7eb19c6512 +bodyq 86a3f7bdf62de7a0 47742b302804c679 +bodyq 86bb4d97aa1153b2 10e009c9091ce8e7 +bodyq 86d9ce774ac0e9b2 85f0e42f8e8c74d +bodyq 873c2b8fb5cc9d7b 6417b3d636d65130 +bodyq 8751c283bf5503b0 6a0f179873bc8fec +bodyq 882bbb80b4c33a2a 7eda61e78dac0fd9 +bodyq 8834877c9b15df09 fc2a1c7e4622144c +bodyq 888095969ea9ab92 4099ca2338a2f546 +bodyq 89776c50b7500989 f20870b464ea3ded +bodyq 89a3b60759c7a791 e86f69a0652c4cf3 +bodyq 89f9967795227eb9 ed4f85ddcbbd7a73 +bodyq 8a7b0fc3a462a5d6 69ee2b27a8fe58f5 +bodyq 8a917b2e79abf10b 488268dabca1e098 +bodyq 8bfad7e73ac0394e a76992bb4bf2d659 +bodyq 8ce48499c2224772 82e00c8853a8acd5 +bodyq 8d0b4c6c4e0b8287 31f69cf40221d4e8 +bodyq 8d5b6014948b0f22 d4bf85c3a8fe81a0 +bodyq 8d74a3b2fe7eb966 ad747ae6548ecd14 +bodyq 8d9a330126fc06e2 e827979c2e30352 +bodyq 8d9a862e3af62f77 8d1855337f957099 +bodyq 8de5991c8f804dc9 5e30ed800e3a5f5e +bodyq 8e8cf68def8d9e0e 8477f01643bf6a45 +bodyq 8ed2c4409443dda9 7e601cea8024fff3 +bodyq 8f9de2b50c6d0080 39e6888f68373548 +bodyq 8feab80bf0370f32 c655a695d6f0fbb1 +bodyq 8ff96a7fddf76be1 8a91a8cddf92679e +bodyq 910bfa248b2227af a134cce96e1d4e32 +bodyq 917b56d82c963db9 1de1881073055c8 +bodyq 917beb804a058565 3011c97dea0d67d0 +bodyq 919157b8dd21ebc5 763261cfcfc65c88 +bodyq 91dcc2e5054d5bab 163ecf585ee24e0b +bodyq 925ba93869af880e f65a450a6c3810e +bodyq 92938b5bcf3574b7 400a856c239f84ee +bodyq 93323bfcca74ac92 68d8e4012f19d23a +bodyq 939d426bd40f34bb 549d87c2a09beacc +bodyq 93b9d305982206ad 43c489981130279e +bodyq 9407348f6ee68f78 884ab98ef87d2171 +bodyq 947bae210f6a7031 f0ce2168f449b11f +bodyq 949ba1dba9a922ee a77efe7f14f3e9e7 +bodyq 954ed37fdd30474a edcfec2ec418ce4b +bodyq 956291fcc7f26916 662dd725a31ce114 +bodyq 964723e6313d848e 982b63de4dedef05 +bodyq 966a40fe1711ccf0 7fcc5da6ddd29a1 +bodyq 96dfca7e24aaf25d 9cf1dfb713e6d02f +bodyq 9777dc82c98426b8 8b6ff531a8727997 +bodyq 977bf6fbb79350d3 cf81edb57a78b4fb +bodyq 979f53be1f68fa7b 5c57fe72b5793025 +bodyq 97f4e74e9c4d232b 146265c8c41dee8e +bodyq 97fb9fe5299fca74 b182a0b583f507dc +bodyq 9854a9f84be0722e 8e2e5da7fc324325 +bodyq 989c038c6c9c954b 6194ef41642ab070 +bodyq 99054039062a6d56 1f3640d81e48f633 +bodyq 9a08845a5a2b39d5 1cf41c4bac1c8719 +bodyq 9aadf5d88db176de e4488fa69b9301c5 +bodyq 9b00b22bd2dfcdea a76992bb4bf2d659 +bodyq 9b15492f093e9680 a3089d32161951a0 +bodyq 9b675b24201a7db1 a467954cd8b8dff6 +bodyq 9c0e8638585adacf ea8cbe3b6f91fa93 +bodyq 9c2fdbb657785b23 80db4a7c91dac017 +bodyq 9cf542d698798567 83682adcddd10c8b +bodyq 9d0250cf760a1cb1 efa89d5aa1c7fb1 +bodyq 9da180752e6d8e5c f14d66bf64adfe58 +bodyq 9db9edf9dc3ccc47 ff53469be1f1f5c9 +bodyq 9dfcef85a5056fc8 22db901c0612b5a3 +bodyq 9e1070f26a7961f5 b4ae9b78082f612f +bodyq 9ea88db302fadbad 2a23573ee1a0dd7 +bodyq 9ef916a9ee01d457 7c835baa124d0e8e +bodyq 9f4839d437dbdb32 4a9a7ae11c727a0a +bodyq 9fe93961fce54ce4 f668039fa596a65a +bodyq 9ff084684afeb19e ec8ca39c726be592 +bodyq a02c8cbee597d352 d55bf3b0eb50905b +bodyq a04d750c20c1a89a bdef9f3eeb68cbf3 +bodyq a077984a0c213988 2518a7d5198c5eba +bodyq a0a05b5b3bae9287 5113344fa8d19e68 +bodyq a0cb16a743107fec 16bdab58112e922f +bodyq a0f0fea3368f2393 6744aab00b0c51be +bodyq a103963d54dbd311 f7719be2bd0a8e71 +bodyq a17366facb51952d 6b9b1bbf7b886c54 +bodyq a188cfc59cab8daf c15d9db7da066f4c +bodyq a1b43eb494b1fcd0 4c84875edd0bbff9 +bodyq a2723c0c456ffc48 1d5241d2f10222b5 +bodyq a2750be13ac0ee82 a82e6c130f123768 +bodyq a2947b425b951a72 3f8d346e233e371c +bodyq a2c7a6bff645bd0b 2aee2c72cf94517d +bodyq a2ea72ba4256d1ad 879d95f99c932a44 +bodyq a3135ee44b475bac 2181840b586288bf +bodyq a3c2bb108cce8ba0 f1901104eb94e83e +bodyq a3f0563f01c103d7 8bcd092e827d4eba +bodyq a41d39c5795ca771 bf16298a0ce75a69 +bodyq a508a4b30b2f79a8 1cbd930fcb8ab321 +bodyq a525edb704ff846a f40227960a4f2ec4 +bodyq a5ae71540871bba8 1f267d5a5bddfea3 +bodyq a604a99ead07e8b7 872b922b0ce6ab41 +bodyq a62599079371da08 ae196a1ca262cff9 +bodyq a649d84498c126fb 791ba1694005b1c5 +bodyq a6575e6a24b93565 773245990a1f93d3 +bodyq a7d38a0353d337b6 907af93716658785 +bodyq a9956363396fd78f 61ef72b1760f3e59 +bodyq aa7daecde0716211 cbe6b0dca8689c78 +bodyq aa8df295f2993c33 1e4f3c995d300a47 +bodyq aafe6cbcf5a00afa d8454a57083b6871 +bodyq ab7a128a7db81697 40f1a8fbafc5e490 +bodyq ab8c28087d812326 4a93b22f5db05a0d +bodyq abb36acf86e05f44 2570de3a1e5dd84a +bodyq abfbbb2620e0ae26 2df80151cfbcc4d5 +bodyq ac2dcd57d0e40382 aa70e59bb7106e46 +bodyq ac5f9599e49c6015 ca6f4ac92d082506 +bodyq ad36ead5dc5ed87e a51810f47ad0fe11 +bodyq adb4262180fbbfa3 5441b0ac871ce5e4 +bodyq aea683ac23a9c3c2 24cd91299aeb5196 +bodyq aee6e643327d448c fa3f219d4c36489e +bodyq aee9bf52c500e4ed ce048e019c4cbd23 +bodyq af029cbd84ef0056 12c6ab8f4baeb9ef +bodyq af2de214b51708e1 f2b41daa216dfbb6 +bodyq b0176088079c92c2 3e24b6358b7be942 +bodyq b02160df0862ef04 7f6e231489fac67c +bodyq b0a2e27b2dbbb205 b1cb1e603f6736ca +bodyq b0ccffd9ae0581de 1d205e6310c30db3 +bodyq b13dd85e39366f9b 32cc20c97ff9b240 +bodyq b16c5ae4b8177443 88d05ace4d7b1bb8 +bodyq b1ca8c69bcb29f4c fa2d4acc9dbfd9d1 +bodyq b25689d132f6a3aa 1d2184842b48387d +bodyq b43807588b3d388c ba7f37f978cc3657 +bodyq b550f7063b5e1658 73b52d02d9ab0251 +bodyq b55f7472956eb256 e01beda4705c9077 +bodyq b5ee57928f707a5b 73d52bb203779843 +bodyq b5fb452f795be5ae ec98d1c97c9d45a6 +bodyq b6774bf12793f446 1e8b37d5df2542e3 +bodyq b70a9e2dff98d690 74693c093f262d0e +bodyq b873090225df1db0 61ff12b12017d5ae +bodyq b8c3995c4bdabe95 79b4a90263002427 +bodyq b8e9148c718a83b2 bfebd40ff05cc62 +bodyq b926ca3a7fe53ba2 cfdeb3e23369bb8d +bodyq b92a296420f44bb4 69ac7249391c8716 +bodyq b999d958d4795300 34fa8f53a21e13ab +bodyq b99d7ec7f2aaacc4 6a9c73495bf602c9 +bodyq ba063e007e4a0089 d09b92d3f9a3343c +bodyq ba0efed99d11207b a01a384bf4690074 +bodyq bac2ec39302668c7 4b17650a45e3ceff +bodyq bad8f1522ba80ebe c38dfa02eb12741c +bodyq bafd9e14f2744298 f5971df58eb55fb7 +bodyq bb14250915d7445f 89cd78a3d909ab57 +bodyq bb4bef0b64fd8d48 6817b4aab3400291 +bodyq bb70288b5c1a5ab3 d38ea1b20ab0fc0e +bodyq bb774894bae68dfb 21affcc78571ffbe +bodyq bb7a2eb4b3663ef8 f85f237f38fbec4 +bodyq bba7ed99af8f5b54 f8b79a9ef5b345b1 +bodyq bbd0138ed7a0d350 89135c3f301a938e +bodyq bbee458ed08455f3 c2d22e62de3f7404 +bodyq bc12021c4f16c7b1 b521a26ac0043960 +bodyq bc2a33a973f536c4 5d7d2e6dec2ce17e +bodyq bcb1ccbb00ea585f 209d4bce881cda64 +bodyq bcdea3156f45a122 b57be7b2da6b56b7 +bodyq bd028205e65b668b 869b7d08657b710e +bodyq bd1086a51723f0a9 9f470ab38227e7d2 +bodyq bd409f1d90d1ec7e c212bb96ec6a82aa +bodyq bd99db1ff250e606 900dbfef441874c9 +bodyq bda35bf5d19afe35 1493c8a31f1fff27 +bodyq bde9492df89e79bb 4c52e0e703230e0f +bodyq bdfa2256a1243766 e5a1917c12f34b41 +bodyq be1c296b4103541a 8725840ac1dbe4e2 +bodyq be5051b47f524a38 c212bb96ec6a82aa +bodyq be608bd98b316613 7c5129ae79242436 +bodyq be6f1003b9147c1e c212bb96ec6a82aa +bodyq be9f5fbfbcf49f3b 4169e9931513e94b +bodyq beab83a2a041144e cc048d64ef21878c +bodyq bfc39e13eac109b5 2e6ace7f25378318 +bodyq bfe88aff0d1cf1ac e690a3273af68c27 +bodyq c04451c38eea2f3d 8563953f20ae0603 +bodyq c0f637cf0b098e2e b31712dd7054004b +bodyq c12ea4bb6c4307ad ef4f1aef648d7bed +bodyq c141ab01fc2a3cd6 9b6388471dfd457e +bodyq c1b86e1466d682c2 702127b453531038 +bodyq c1d80e01a94fb0d5 e2e86906aa3921f +bodyq c2b7d06e06237be2 945549dd02ed03c8 +bodyq c372db7ad947ef02 19561f0bb2d3d182 +bodyq c37c3f7fe77cffe5 d5d516f9bf4b81b9 +bodyq c5ae5fa47a4cf64f 38cca871825e845 +bodyq c5b0ee41b05acfb2 50b215cf3a434e35 +bodyq c60ec2d90df2762b a67d7744695d632c +bodyq c6a6785521523225 977a5116dc7ca01e +bodyq c77b05daa4ff56ae f0fdc5876f55e901 +bodyq c7a2188f77229408 9bcbfeaa2d51f9fa +bodyq c83cc8417e632ce2 be4e2282531b446c +bodyq c853f415c89a1d6a 7cc1320ac2c2fd9b +bodyq c85f0d56aa6253b5 c96e9c0a6dc0a093 +bodyq c8c1c712735b0ccc f8f8056249830ba +bodyq c90f6570abc63716 d13ab29f75df4f09 +bodyq c9417f90820411a2 59917c062e09bf1f +bodyq c972eb6e158540ea d8a0151f4474172c +bodyq c99075537de59d30 46e0fffdd9355356 +bodyq ca51003e7d5cf837 3d0d27558d14c764 +bodyq ca612ead4b39d244 e8b121d45bfdb5d9 +bodyq cb0370a917abe0df a26811e405ac0457 +bodyq cb488f245b2795da 225b8767d8f9d66a +bodyq cba3c34ae22ec49b 4df133e09a3245c2 +bodyq cbaf49d9aa662ec8 16f5cb4561e7ad86 +bodyq cc256dd80b037384 9cf1dfb713e6d02f +bodyq cc329731922ce601 a76992bb4bf2d659 +bodyq cc383f93cc4a1177 173b4ad338f9a380 +bodyq cc527f122cca2eee c0649a89a6b206ed +bodyq cc71575aafbcac28 1fba261b6ec3cd9f +bodyq cd44a949d2c08a9d 70f93ebdc79247a3 +bodyq cd514d3bf7b25bba 7e6c1c8ffcb9f39b +bodyq cdbea33e56e46c5a 363efa271aabe15d +bodyq ce20725a9f915d01 344fab64b6469195 +bodyq ce275778f4d4977d 36a33e51eac08e7e +bodyq ce288f34f8e6e4af 4127d68c931533af +bodyq cf024f76b08afd38 c95b8e3f6368717e +bodyq cfdc0c8bc7aaaf12 6190e21bd2575263 +bodyq d05a659360ba6f3c 4de825a3cf99797b +bodyq d0dc0130bbaffff0 28b00af883b69463 +bodyq d1d5db6a15028a9b 4d3101a4dd70256e +bodyq d2085fbebccdffda fbe1ae98fa34ce9a +bodyq d25a3ce67a83b39b d19e28274dbe237c +bodyq d27009c85a85fc05 31f32f3df622f18 +bodyq d2b8b85442a71925 9bfce90e16556a66 +bodyq d2d13b315fa7cda4 1f3f133afec5414c +bodyq d3c8d6b28ec1e96c e393230aa01c8bc6 +bodyq d3f0565c6040da91 29acfcedd9b23ed3 +bodyq d3fcd81e343f10ef fd65bad9034c6232 +bodyq d4885e4f3e20b47b 3543ed12bfa62cad +bodyq d491b0153cfde2d8 5091375f1a31d86e +bodyq d4d8059fa01f1e3c bd7d3750b548c567 +bodyq d4dce2d2b3a5855d 25a69397f958eea7 +bodyq d4e70b54741103c0 fa27445bb936465f +bodyq d50d180e3f8249f7 602b12e6efd5441e +bodyq d560adb4a23e2cff c3c55b0834289249 +bodyq d5b13308dc43b558 fde3eb7c136ff648 +bodyq d5bb33cd7f8fba6b 33ced12998538f1b +bodyq d65071a443bfd4aa 3be826af6f1fb62 +bodyq d72258d6542408c2 849d4b596180cbbb +bodyq d78a9a1b73a37bc2 8e53a925056c1993 +bodyq d7bdfd13e38c53eb 836f51e419403be +bodyq d83135f37d441e8a d3c8ccea6de0e29f +bodyq d85fa2a1d24a1d9f 2a3173533eea62ac +bodyq d864f94da21ec08c 365d59208035d7aa +bodyq d8bec32aecbb5851 b21cab125d810d64 +bodyq d8cb3bc23bf7e3c7 d8f902b9a7fd0e0d +bodyq d8e1d53e3c403ae2 a4dbe0fb3162383c +bodyq d900a7d57a7a6e51 3c6cdb9e44caa11e +bodyq d94841dd9e08985b d68841a7518357b9 +bodyq d9a72ef3652ab4bd 30d74ede46e8192d +bodyq d9b34ca129cedb0a 76042170ab736cfc +bodyq da2f7f459c5ddf7f ed0b8cc474a3d899 +bodyq da42d957244e0f81 a3e1c5b660408119 +bodyq da62006c9ee9330e c212bb96ec6a82aa +bodyq da950ddf8dfe0436 6c922fdccccefdbf +bodyq dafd603c2f091213 61c46b8ab26e035e +bodyq db6e9f0bb941444e bb447eb40bc8d038 +bodyq dbee05807e339e8d 8ebab3c4bc84cada +bodyq dbf55aba3ae608cc c1a053575fcff88e +bodyq dc57c1548a19f6ef bf2c29f05fd9d33b +bodyq dcd3b9c5600c74ac 67e71ba50cc06a32 +bodyq dce93392eeee89dd 928aac40caef4d76 +bodyq dd55181effa0995b 2dd8567421941743 +bodyq dd7f4ba2505ff33c 7279b27ddc078f21 +bodyq de0c720a86e29e55 336573380094d045 +bodyq de35fac2281b4eea ef951a01f2e63716 +bodyq de6229cf181cd846 277912ef85473b1a +bodyq de6f49bdb8c48e7b e887317aa3c302f2 +bodyq de9df27d11fd6d43 cbdc5ed7442ba073 +bodyq df0e0f92f2092438 f1bd9e56ec4f25df +bodyq df22835a99ccf5bc b79dc6d0e3b3bb78 +bodyq e0d588512343fa21 dfca1ee87a59514e +bodyq e10010a21b629e66 a76992bb4bf2d659 +bodyq e146ee1a4a342aac dd2080ec40c009bb +bodyq e1b4c1103fc2ed5b 95eb1b401c8b002a +bodyq e23c2226eb4158af 4a7210d90e0f3e7 +bodyq e2699fd6a3fa1328 fb8ecc4bae067429 +bodyq e2fe3265394778ce c19108351c973b35 +bodyq e300b1faf6e1d012 4e3d2c800c566b53 +bodyq e36cf8c9d9f1cd89 defa8237d682b9ac +bodyq e378ef5afbb3e408 aa96924be4397f44 +bodyq e3a90ae9514813f6 5cd81be0857b0b55 +bodyq e3ad8a7c6e96972c 193a8fbe2713ac0a +bodyq e3d2d3a21b154af6 81f665de3a6cb2f7 +bodyq e40b81d45cf6363d 4cede33f455de904 +bodyq e45f0d4802d2848a 91cbe1313e7911df +bodyq e46ab7a2d342d4e1 e2418c1dcda444d1 +bodyq e475358369fb36cd e206c629f795dc7e +bodyq e49178f48c0574fa d1551933eba7257e +bodyq e4c073933f1e47b6 3132756f0aea2c07 +bodyq e51a8d8ddd82a50d 917cd828b3c43f64 +bodyq e53f1181d710f13f 52ede5e3b5b13e46 +bodyq e57a61300c415efd 9b3cbda2538d657d +bodyq e5eab62fa164f5df 95b3d1b8f14c2ec0 +bodyq e6378805498ae207 9318543c7afc9c8d +bodyq e65813db8d9b2bc6 11c9afdc305ee260 +bodyq e672f75e6728a41b a4a5e099e7a0586f +bodyq e6e4049fbb37155b ad2e5aeeec1c2532 +bodyq e7afc20b0204158a d49b0c2b3ad2b82d +bodyq e81adba88c94d542 e98d4101192666b1 +bodyq e81be21ddfb4b2ca 3685dbe6774a1ff1 +bodyq e83f724256ae1c03 8c8c31f91fe92ab +bodyq e8bf877e84fd93c8 bfe898cf572d81bc +bodyq e963d0798b6c2416 49738ee9b27a94f +bodyq e98c2bc92448636c ae86f04744c74d7a +bodyq ea67c8fe6f523c94 6346fe9cb10b5d29 +bodyq eaaca8031d102a8a d7a46f494a9cbd89 +bodyq eabdb1c11c0b7344 a70bceba6c4a44db +bodyq eaf3758011d7fd20 1fe0a14f23a15a +bodyq eb21547e8fc5798d e4a29ceafaaebab0 +bodyq eb59730bea76712d 3269211ed02a5d37 +bodyq ebd54b9559a8d1dc 4a7f6f3a29290e78 +bodyq ebe6c7d83a7b0b88 736e2d8a8cc0b787 +bodyq ec0a3b87d49de600 7df190b12f71e12a +bodyq ec23757266779a3d 4c69b8d505f96602 +bodyq ec238b174e9bc2c2 8554588d638f9b9f +bodyq ec571d26837759c3 d4a131408c2c2cd2 +bodyq ed0f421581be922c b8af40fdd741fa5c +bodyq ed51829342734599 74fd94119527913e +bodyq ed5af324fbb452ce 2eefea4374f7b86 +bodyq edc3a81a5285678e d281d5bbe92724d6 +bodyq ee8b47838e145fcc 8d3577667856d986 +bodyq ee9fc2ad102cff33 3543ed12bfa62cad +bodyq ef0b1c5fa0b90ab9 5e71805b412317f3 +bodyq ef1d53b8c44bbb67 280f92fe3abd208f +bodyq ef29cc93418187ad 4578e31af0640631 +bodyq efb4a906769bc50a f4144511df543411 +bodyq f03f21e4f1638e0f 3043a961b4fdab84 +bodyq f103a8f1cc586bbe 8cc275aaa8e1b31f +bodyq f148d5a58bdea0ef 6841488612cecdd0 +bodyq f189dcfe4fda591e 1da68d34a080e017 +bodyq f197817f969bf7c7 db90d32ba9d1a1ef +bodyq f1e3e5d2694a3263 d5ed46cc8bbad911 +bodyq f2b1cab4ba10688d 318150455d49af7d +bodyq f2e357d4e331608a 2a9fe06bed1ec009 +bodyq f3522319ec73e6b5 b8b6835485eaf91d +bodyq f375a8b30995f676 f1b5441222dfff49 +bodyq f38296f6730d0380 eb839050b266d28e +bodyq f38d7d97db1cc3a5 1d686b614ea5fb84 +bodyq f4af891e3663df17 9842af2c0ca33581 +bodyq f5da182a28999848 987f44651389bd9c +bodyq f6499fa9b56dd88d 67a07f492be50138 +bodyq f64f3b1838d23608 95da0f8038561b69 +bodyq f69b15b433a0ebb1 9046ff870769ad93 +bodyq f6cd3e68decce213 5ef994ef59a1da6f +bodyq f6d09aaa879795f5 59f6fd854be79785 +bodyq f740404a18ee9317 ada20e415e9aeb23 +bodyq f83dbf2f26b3d9d6 16769864e65ec1e5 +bodyq f85e3c71bf15f382 a74ad0f23306ce4d +bodyq f8ff1b999d5139e6 23ea3e0924d82622 +bodyq f90db1a16f62ee03 34e61e90775b4dcc +bodyq f953ae6dc1284559 49a911428c9907a4 +bodyq f9710e2cab88cd14 4950b9c6cd84f7c9 +bodyq f9bbc08faef1cd9a 5beff2a53aa51df6 +bodyq f9f84d21d21eb570 98bc7591bc156b60 +bodyq fa47b525839d3e9b 533edae10fcff826 +bodyq fa6e14ca0bb22b76 9f2bdd2875ce85d1 +bodyq fb57d0b51a01ea3d d281b13dd86efea5 +bodyq fc41f933bfe74d4c 73c86d6da7fb9461 +bodyq fc7f155243219c03 6b2bef7ff57e8a54 +bodyq fd19ef11039692b7 28a4b0067efba7d +bodyq fd619dcdcef1e43e e393230aa01c8bc6 +bodyq fde1a69149bde1e8 48246929961d8898 +bodyq fe09c2eea1d66805 904704609fdbb2a9 +bodyq fe21950435627c9a 5b1f3835810c3e20 +bodyq fe7c1a71bbacd6c7 320a2e6c3b5dc4db +bodyq fedbe7b3fffd467c bf73c4137f6fd188 +bodyq fef21dd5727bbb51 1ff61376acf901ad +bodyq ffd1fc17b731e639 e7d41bd97d784b66 +bodyq ffec8b3317b77318 34a6a09d44f4cf78 +clone 1ba7bba9919a7f3 +clone 23cd323236c47cb +clone 25c7d133bb98b03 +clone 32c7b76eefe285c +clone 3532a0725cacf47 +clone 38d3bf36b586c62 +clone 45f252a31edf150 +clone 4d709dcff67019c +clone 64ee2c7a75b60e4 +clone 6f55e6cd767f894 +clone 954d45a262762cd +clone 9e4ac7e901ee682 +clone a4879539993db9b +clone a86a49de0c58487 +clone b21dc88e06feeca +clone b3e7d353e3fff1e +clone b67d2b894cf2fb0 +clone bf1dd56a9bf1f1a +clone c13ba17a5cdc198 +clone c6bf7fb518d58e2 +clone c941f2dfb04900f +clone cfe33f4dc3db0b7 +clone d48a264ab625c3a +clone e8e2ba201292eb0 +clone f2709d7dfcef9c7 +clone 10a1be75e321f840 +clone 1116af5284e45afd +clone 130dfe290a7b2598 +clone 13253e08fef6f082 +clone 13a3fb76805cbfa7 +clone 15b05ce60c3529e0 +clone 16e8a17aa76b3073 +clone 1729bff30ca09ee1 +clone 1804f8b594867496 +clone 182e25536ad28cae +clone 1986e86165861a60 +clone 19ed2e477007bf7c +clone 1a11010ed5001c93 +clone 1afc0b1ec3bb1161 +clone 1b1bbada54bd9e15 +clone 1b4985d6c70d6028 +clone 1ba6b367eabb4108 +clone 1c3e7cd91f04099e +clone 1c97269259e6a55e +clone 1d14fb278c83be46 +clone 1eec88c1cd7a0ada +clone 1f91fc07515977ae +clone 1fa6e8b2095b32fd +clone 2013ac0dbc78353c +clone 20eca5ee4b7cc2f9 +clone 2120a9d10d43009e +clone 21a45cc25c5d0790 +clone 2339688de92916e3 +clone 237bb338344cef1a +clone 254a90deab024826 +clone 2555cbfd10294e34 +clone 258b7d90cf0a2500 +clone 25c3d1666bc813dc +clone 270af551573de700 +clone 2801e9062269ee7d +clone 2808853ea351ca59 +clone 2a6bfb360140bcb0 +clone 2bd83c15ebd139ce +clone 2be988adcd44d106 +clone 2c129ee68f531815 +clone 2c26d7b209d265d9 +clone 2cb0a93b93db496e +clone 2cd50862bf3c1b1d +clone 2cf2e1b7ab3722df +clone 2d0e0254b9ade5c1 +clone 2e782736b2b783f6 +clone 2eca5b9ea95cd704 +clone 2f0991ced6c61caa +clone 303ebc3649b5dae4 +clone 31e78dd0cb270e62 +clone 3228356cff6d0624 +clone 3261d4e84e2b2e92 +clone 32668c4d1e9a928b +clone 32a41a9db1491b99 +clone 3321a7bb0f8b618c +clone 3385d8748a44d9d7 +clone 34a79507b270d577 +clone 35612cb9dba97284 +clone 356ee6328ecd743d +clone 357484fabc505cee +clone 35ae13bb7d2b910e +clone 3680343f11086dba +clone 37a0da06a04fb2e0 +clone 37baddefe0de1695 +clone 384e48d8e92162f9 +clone 3982936169d2cd63 +clone 3af4bc15f4c29534 +clone 3c10144ad2f3bd70 +clone 3cec32c526b56d3a +clone 3d831cb5ba0a4bd7 +clone 3dcb24d59f96a8eb +clone 3e8724644129273d +clone 40912c91c962e151 +clone 411a1619555c5c3f +clone 422ed064ec894fb7 +clone 424fd01ab8d56cd0 +clone 4441dd797e14e5ac +clone 45e9bdba7af5c147 +clone 464f049047947ce7 +clone 468e923c2e95edd3 +clone 468fc6a698c06d4d +clone 46f8aa65ec7e1483 +clone 474932a77a533900 +clone 484f0b9451e0f7eb +clone 48fbb9ed2ff9ca5b +clone 4b14646fdb4b7e8d +clone 4bea495712bbce79 +clone 4c58637493ef3870 +clone 4c9710452d4c58c9 +clone 4d3a3b9da20599c5 +clone 4da651eca45ee96d +clone 4dfa249b8c39b3a3 +clone 4e548bc19bec6aaa +clone 4e6244206aca4783 +clone 4f66f76abe7323fa +clone 4f817b1089b1ebce +clone 4fbe6b45a769b5c4 +clone 4ffd9c1483cb44e5 +clone 500687314c09b6d5 +clone 52e86a2bcc1d34a9 +clone 53425cfcd31533b4 +clone 53e1f97829d90b50 +clone 54fe60869183f34c +clone 552ae9091c598dfc +clone 553a5749fb949dae +clone 5687f9428fbcd2f8 +clone 56c16892bd0067e9 +clone 574ed0b3f02a430a +clone 57e2d76a644a1fc0 +clone 5838089d2af375ee +clone 58c86258cc2a6c7b +clone 58d57e46b854cc3e +clone 59515f644cc44d21 +clone 5964f40bfa65c75a +clone 598192b3a8cb5ea9 +clone 5c005753c956c282 +clone 5da9b60c40e1abdc +clone 5f257cdc7b720a70 +clone 605b9566b79aad2e +clone 60f6609588f0a9be +clone 623403fb399bff40 +clone 63fd877b6fbb0912 +clone 657196cf95d9a800 +clone 66f5949363966943 +clone 6883eed880ff064c +clone 68da40f9266956d9 +clone 68ed5044fef475de +clone 6d6ffc4024a3dcbe +clone 6ddf50381ccbb4ea +clone 6ea8880c36dff831 +clone 6fdab2838fcbb2d7 +clone 703834e8e3d159c4 +clone 7080966c9f047cda +clone 708f5f9669f2fac2 +clone 7125737f5c634369 +clone 714388a60abb559d +clone 7157ae12050739fc +clone 73ac25eb78a8de2e +clone 73f6adf3d8410765 +clone 742b58910a74b439 +clone 74471831da6653bc +clone 74eb9faf62a042d6 +clone 7519bd483993067e +clone 75ba6b1ba48c6bbe +clone 76445fb462faf64b +clone 7660f05cfecb8977 +clone 76ffe65093d8371c +clone 770d5603870e3d03 +clone 7730a797d7b52163 +clone 77b08804ad1cc974 +clone 77caac56ccc94557 +clone 77d097ca6a4f9f50 +clone 787963162c8295fc +clone 796d7d6ba479098b +clone 79c693d7125eb936 +clone 7a4340f004571b97 +clone 7a5a8f225ffdbc01 +clone 7ac71d3c4372e738 +clone 7b4e42a8a916c86d +clone 7b94c027f34738e3 +clone 7b99fa2e702e4db1 +clone 7bea2019397b09c3 +clone 7c54777b980fefec +clone 7d87878f38e5b48c +clone 7e385cb5a27957e2 +clone 7ec7b5930daebcac +clone 7f2608732013d6da +clone 7f287a11ae16422d +clone 7f44a10c33ee2a3d +clone 813dd7de29435f44 +clone 83442d6937fad3a8 +clone 8355280c441c8755 +clone 83d049dc74748042 +clone 862c9309bc653412 +clone 87b531d06b9a46a1 +clone 885abc517bafaa2a +clone 8895b0064f90e1d4 +clone 88a38a228f37bced +clone 891cbcee4c1ab9a3 +clone 8956bb84fc4a2610 +clone 8a0f70168ef3c34b +clone 8ab649f969fa819a +clone 8b50087d53dea7d7 +clone 8ba5169d60bfb4c7 +clone 8ba6faa27fe39a72 +clone 8c21b10381b11d36 +clone 8ce914db1d60111d +clone 8d41ee36ea2866ae +clone 8f086b7b74216720 +clone 8fb592af561af91e +clone 9002e256191e4d55 +clone 904b4c49c35c82ca +clone 91fc0604dfc2ad1c +clone 92d5c84b6f992cb4 +clone 93c583b3d41ca30f +clone 940efce5759b9668 +clone 944c082aa6779fba +clone 96895992d6f3c772 +clone 9695da8b15c23f2f +clone 969c13b38d384d6a +clone 97f4b5deec01897b +clone 9853cb9abbcd8fad +clone 98710621593a4c6a +clone 990f76437f66e5e5 +clone 9937af383dff5208 +clone 99dbf30eefde2fbc +clone 9bd2cf827f105a1f +clone 9c1a1b02bda3b9b7 +clone 9cc1e24fdaf427a3 +clone 9d4ac2f3da444be4 +clone 9da5870d8e2c947d +clone 9e61039cfc6a28a8 +clone a0dafc2d2701b3a7 +clone a16efe95594ddc2a +clone a3c90ee80cb0a2b4 +clone a41afbdf2655f320 +clone a46772792b61712b +clone a4e25d8e191ad8b5 +clone a4fb6a583ec6e143 +clone a557593f8784fb18 +clone a56a588e2363ea21 +clone a67e0c8b53bfc567 +clone a68f30be98b5de1b +clone a7527e556341bc43 +clone a9386223648eee2a +clone a980240b92b19f93 +clone aa7c0332d2393d0e +clone ab95b3096c44670d +clone acc05659fa9e6fac +clone ad10c51ceaa43486 +clone ad47bfa93da91586 +clone ae58ed2f0141b8e9 +clone ae5e4abf4c5837a9 +clone afc4e501d5988d50 +clone b0650072535a8a51 +clone b0addd69300d5a60 +clone b2c01e874f823fa6 +clone b4f502103e1704d6 +clone b6887a7bef788d6c +clone b701f05eb37cb34d +clone b7fca3171da390d4 +clone b8bff67139b3ed6f +clone b8e726d0e62e22ec +clone ba20e14a19adc717 +clone ba899d33c2e25a05 +clone bbb46bde09bd0982 +clone bbedc214c7c006bb +clone bc946a990599a97b +clone bce4fb0cd683fb96 +clone bd8c0207cb6b28b1 +clone bde0717e30cce7df +clone bef48502d7137d27 +clone c19f5d958e842fa1 +clone c2d372ffa35231dc +clone c3d867a684186827 +clone c3d95cc87f5b5d6e +clone c4831cbbab9f58c1 +clone c551e418ed09941a +clone c57bbf62d14444aa +clone c5dd6ee57ff24cac +clone c668c61e0788f0e4 +clone c6f1a80ac44a9b05 +clone c709fbe98eb5373e +clone c8488201ea906d13 +clone c92bced28bb95275 +clone c945a2fd4e579899 +clone c99d4a3c52b33185 +clone c9adde640f07ed3d +clone ca1394f9d57d7823 +clone caffcec8c775bd95 +clone cd9ecae514b85378 +clone ce5a42c893305e95 +clone d044faffab12a67d +clone d059bc5130de9f9b +clone d10641d8a07dd2db +clone d112b0a4588f540b +clone d187ba235ae22027 +clone d1a81692ace2148a +clone d20d3fa4e1a30323 +clone d2a68b0329ce775f +clone d2c15ed5ca3fa070 +clone d39533f69af58a0b +clone d4387f9940260f91 +clone d46016b72a4359c3 +clone d4c7aad20e46a57e +clone d4d57d899d462af0 +clone d5617e24b2529ad0 +clone d58352f977370b45 +clone d6476d3c97ecd85f +clone d7bbbe769de83c64 +clone d7d08eacd83b62c9 +clone d82838f23b101788 +clone d9714d04a6a4fafc +clone d9c571ae0f1bf5aa +clone dacab2a9eafcf0c7 +clone dafb815196333513 +clone dbcd1780e5519531 +clone dc61752d56e806cf +clone dddfc448e68eb893 +clone dee8a24d19cd3bb9 +clone dfa8e1ad955430ce +clone dfe0c24aa484163c +clone e0ca8af9c69f0e24 +clone e1e1f1dffd7ffd40 +clone e2cc726d6403e744 +clone e30fe3bbe937ad7a +clone e345eea993e32720 +clone e3d0b34e6a198fe0 +clone e4aa46d7bd17e079 +clone e64c355ef2b36dd6 +clone e75f59f109d8212b +clone e7ec9299bc82cdda +clone e84306cbd05e17da +clone e93ee0eeed403168 +clone e9f27ff96c3def5f +clone eb5d58744970a615 +clone ec0002a420ce20b7 +clone ecbd74ca5bc0a27e +clone ed081f02912bd2c2 +clone ed3e6b7296c4ff6e +clone ee1245857ebd9f1d +clone ee77e200aa0a499e +clone f1266d719dcdab59 +clone f1762ff3c0a9f43e +clone f1ce0a09fcab3943 +clone f55c082b13ecf2f4 +clone f6fbffcd5cee28e3 +clone f889cca4a09d9c1b +clone f95e744f90978a0c +clone fce54cc1b4e90617 +clone fcf2e8a901877d4d +clone fd88f6f9199da43d +clone fd9f287303ec37cf +clone fe1f4cc5db929af3 +clone ff3198f61a101b57 +clone ff4eb20ceadd3528 +clone ff5da900dee24762 +clone ff731b8019252266 +clone ffd3e8ddead9dffb +dead 6593a5202605144 +dead 9290bff224c908f +dead ce934e313754b1f +dead d2f8bf6bd734dbe +dead 11e1842b0846e3c2 +dead 12d781289b1f6537 +dead 16b12a8a9877f141 +dead 190843bf16d81bd4 +dead 1e539f3e54012138 +dead 220a016c123269d2 +dead 265eeb107f4f7037 +dead 2a9e43e7514907ba +dead 2e8b4e73d01cda1d +dead 324c715b3ef59fb5 +dead 3ad308b61b8f874e +dead 3b670e3e6581709f +dead 3ce01651a41aafb6 +dead 3d903cc2ced5fe66 +dead 47f97c0fb37c8568 +dead 4ad0bfd06eca3b10 +dead 4c031399d70c63d6 +dead 4eb915efc09067e9 +dead 58c2008305b0afe0 +dead 61fe78aa31fda57a +dead 670c6a44b9bdada6 +dead 73e074eadb44c9f7 +dead 74798dc894205762 +dead 76c1f41550b1b997 +dead 7a2991b69512efe4 +dead 7cbebea3fdb2d0f2 +dead 823b34f6ccc08503 +dead 861e750aa9a0a707 +dead 8642302fa250c337 +dead 93b9d305982206ad +dead 9407348f6ee68f78 +dead 956291fcc7f26916 +dead 9c0e8638585adacf +dead a5ae71540871bba8 +dead abb36acf86e05f44 +dead ac5f9599e49c6015 +dead b43807588b3d388c +dead b873090225df1db0 +dead b8e9148c718a83b2 +dead b926ca3a7fe53ba2 +dead b99d7ec7f2aaacc4 +dead bac2ec39302668c7 +dead bb774894bae68dfb +dead bb7a2eb4b3663ef8 +dead bd99db1ff250e606 +dead bda35bf5d19afe35 +dead c5b0ee41b05acfb2 +dead c60ec2d90df2762b +dead c6a6785521523225 +dead ca612ead4b39d244 +dead cd514d3bf7b25bba +dead ce275778f4d4977d +dead d27009c85a85fc05 +dead d4885e4f3e20b47b +dead d5b13308dc43b558 +dead d83135f37d441e8a +dead d864f94da21ec08c +dead e45f0d4802d2848a +dead e46ab7a2d342d4e1 +dead e5eab62fa164f5df +dead e7afc20b0204158a +dead e81adba88c94d542 +dead e8bf877e84fd93c8 +dead ee9fc2ad102cff33 +dead f6d09aaa879795f5 +dead f8ff1b999d5139e6 +dead f9bbc08faef1cd9a +dead ffec8b3317b77318 diff --git a/apps/cuser/admin.py b/apps/cuser/admin.py index b1b8037..60459d7 100644 --- a/apps/cuser/admin.py +++ b/apps/cuser/admin.py @@ -1,4 +1,7 @@ +from utils.admin_base import CRMModelAdmin from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from apps.cuser.forms import CUserCreationForm, CUserChangeForm from apps.cuser.models import ( CUser as CUserModel, CuserRole as CuserRoleModel, @@ -8,23 +11,53 @@ from apps.cuser.models import ( # Register your models here. -class CUserAdmin(admin.ModelAdmin): - pass +class CUserAdmin(UserAdmin): + list_per_page = 50 + save_on_top = True + date_hierarchy = 'created_at' + form = CUserChangeForm + add_form = CUserCreationForm + list_display = ('username', 'name_family', 'mobile', 'is_active', 'is_staff') + search_fields = ('username', 'name_family', 'mobile') + ordering = ('username',) + readonly_fields = ('last_login', 'created_at', 'updated_at') + fieldsets = ( + (None, {'fields': ('username', 'password')}), + ('اطلاعات کاربر', {'fields': ('name_family', 'mobile')}), + ('دسترسی', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), + ('تاریخ‌ها', {'fields': ('last_login', 'created_at', 'updated_at')}), + ) + add_fieldsets = ( + (None, {'classes': ('wide',), 'fields': ('username', 'name_family', 'mobile', 'password1', 'password2', 'is_active', 'is_staff')}), + ) -class CUserRoleAdmin(admin.ModelAdmin): +class CUserRoleAdmin(CRMModelAdmin): + list_display = ('id', 'cuser', 'name_family', 'mobile', 'role_type', 'sys_ws', 'is_active', 'customer_panel', 'is_admin') + search_fields = ('name_family', 'mobile', 'cuser__username') readonly_fields = ('created_at', 'updated_at') -class RoleTypeAdmin(admin.ModelAdmin): - pass +class RoleTypeAdmin(CRMModelAdmin): + list_display = ('id', 'role_name', 'role_name_en', 'created_at') -class SmsOptAdmin(admin.ModelAdmin): - pass +class SmsOptAdmin(CRMModelAdmin): + list_display = ("id", "mobile", "expire", "created_at") + exclude = ("opt",) + sensitive_fields = ("opt",) + + def get_readonly_fields(self, request, obj=None): + return ("mobile", "expire", "created_at", "updated_at") + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False admin.site.register(CUserModel, CUserAdmin) admin.site.register(CuserRoleModel, CUserRoleAdmin) admin.site.register(RoleTypeModel, RoleTypeAdmin) -admin.site.register(SmsOptModel, SmsOptAdmin) \ No newline at end of file +admin.site.register(SmsOptModel, SmsOptAdmin) diff --git a/apps/cuser/cuser_model.py b/apps/cuser/cuser_model.py index 0c576d2..5ad3659 100644 --- a/apps/cuser/cuser_model.py +++ b/apps/cuser/cuser_model.py @@ -1,6 +1,8 @@ from django.db import models from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin from utils.base_model import BaseModel +from django.core.exceptions import ValidationError +from utils.validations.mobile import normalize_mobile import uuid @@ -8,9 +10,11 @@ class CUserManager(BaseUserManager): def create_user(self, username, password, **extra_fields): if not username: raise ValueError("Please Enter username") + extra_fields['mobile'] = normalize_mobile(extra_fields.get('mobile')) user = self.model(username=username, **extra_fields) user.set_password(password) - user.save() + user.full_clean() + user.save(using=self._db) return user def create_superuser(self, username, password, **extra_fields): @@ -31,6 +35,7 @@ class CUser(BaseModel, AbstractBaseUser, PermissionsMixin): uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) USERNAME_FIELD = 'username' + REQUIRED_FIELDS = ['mobile'] objects = CUserManager() @@ -44,3 +49,9 @@ class CUser(BaseModel, AbstractBaseUser, PermissionsMixin): def __str__(self): return f'{self.name_family} - {self.username}' + + def clean(self): + super().clean() + self.mobile = normalize_mobile(self.mobile) + if type(self).objects.filter(mobile=self.mobile).exclude(pk=self.pk).exists(): + raise ValidationError({"mobile": "کاربری با این شماره موبایل قبلاً ثبت شده است."}) diff --git a/apps/cuser/forms.py b/apps/cuser/forms.py new file mode 100644 index 0000000..dc31d7b --- /dev/null +++ b/apps/cuser/forms.py @@ -0,0 +1,30 @@ +from django import forms +from django.contrib.auth.forms import UserChangeForm, UserCreationForm +from django.core.exceptions import ValidationError + +from apps.cuser.cuser_model import CUser +from utils.validations.mobile import normalize_mobile + + +class MobileFormMixin: + def clean_mobile(self): + try: + return normalize_mobile(self.cleaned_data['mobile']) + except ValidationError as error: + raise forms.ValidationError(error.messages) + + +class CUserCreationForm(MobileFormMixin, UserCreationForm): + mobile = forms.CharField(label='شماره موبایل') + + class Meta(UserCreationForm.Meta): + model = CUser + fields = ('username', 'name_family', 'mobile', 'is_active', 'is_staff') + + +class CUserChangeForm(MobileFormMixin, UserChangeForm): + mobile = forms.CharField(label='شماره موبایل') + + class Meta(UserChangeForm.Meta): + model = CUser + fields = '__all__' diff --git a/apps/cuser/views.py b/apps/cuser/views.py index 43cbe32..5f1b3cb 100644 --- a/apps/cuser/views.py +++ b/apps/cuser/views.py @@ -3,15 +3,21 @@ from apps.cuser.models import CuserRole as CuserRoleModel from utils.get_cuser_role import get_cuser_role_uuid from rest_framework.response import Response from rest_framework import status +from rest_framework.permissions import IsAuthenticated # Create your views here. class CUserGetInfo(views.APIView): + permission_classes = (IsAuthenticated,) def get(self, request): - + cuser_role_uuid = get_cuser_role_uuid(request) cuser_obj = CuserRoleModel.objects.get(uuid=cuser_role_uuid) - return Response(data={"name_family": cuser_obj.name_family}, status=status.HTTP_200_OK) + return Response(data={"name_family": cuser_obj.name_family, + "role": cuser_obj.role_type.role_name_en, + "workspace": str(cuser_obj.sys_ws.uuid), + "permissions": {"manage_products": cuser_obj.role_type.role_name_en in ('workspace_admin', 'workspace_owner')}}, + status=status.HTTP_200_OK) diff --git a/apps/customer/admin.py b/apps/customer/admin.py index 757acd3..6a95743 100644 --- a/apps/customer/admin.py +++ b/apps/customer/admin.py @@ -1,3 +1,4 @@ +from utils.admin_base import CRMModelAdmin from django.contrib import admin from apps.customer.models import ( Customer as CustomerModel, @@ -11,36 +12,48 @@ from apps.customer.models import ( # Register your models here. +class CustomerNoteInline(admin.TabularInline): + model = CustomerNoteModel + extra = 0 + fields = ["desc","cuser_role","created_at"] + readonly_fields = fields + can_delete = False + show_change_link = True + + def has_add_permission(self, request, obj=None): + return False + + @admin.register(CustomerModel) -class CustomerAdmin(admin.ModelAdmin): +class CustomerAdmin(CRMModelAdmin): pass @admin.register(CustomerNoteModel) -class CustomerNoteAdmin(admin.ModelAdmin): +class CustomerNoteAdmin(CRMModelAdmin): pass @admin.register(ShiveAshenaeiModel) -class ShiveAshenaeiAdmin(admin.ModelAdmin): +class ShiveAshenaeiAdmin(CRMModelAdmin): pass @admin.register(ResponsibleGroupModel) -class ResponsibleGroupAdmin(admin.ModelAdmin): +class ResponsibleGroupAdmin(CRMModelAdmin): pass @admin.register(CustomerResponsibleModel) -class CustomerResponsibleAdmin(admin.ModelAdmin): +class CustomerResponsibleAdmin(CRMModelAdmin): pass @admin.register(CustomerGroupTypeModel) -class CustomerGroupTypeAdmin(admin.ModelAdmin): +class CustomerGroupTypeAdmin(CRMModelAdmin): pass @admin.register(CustomerGroupModel) -class CustomerGroupAdmin(admin.ModelAdmin): +class CustomerGroupAdmin(CRMModelAdmin): pass diff --git a/apps/customer/migrations/0008_customer_mobile_unique.py b/apps/customer/migrations/0008_customer_mobile_unique.py new file mode 100644 index 0000000..b1f569a --- /dev/null +++ b/apps/customer/migrations/0008_customer_mobile_unique.py @@ -0,0 +1,60 @@ +from django.db import migrations, models + + +def canonical_mobile(value): + if value is None: + return None + text = str(value) + if len(text) == 12 and text.startswith('98'): + return int(text[2:]) + return value + + +def normalized_rows(model, alias, per_workspace=False): + seen = {} + updates = [] + fields = ('pk', 'mobile', 'sys_ws_id') if per_workspace else ('pk', 'mobile') + for row in model.objects.using(alias).only(*fields).iterator(): + mobile = canonical_mobile(row.mobile) + key = (row.sys_ws_id, mobile) if per_workspace else mobile + if mobile is not None and key in seen: + raise RuntimeError( + f'Duplicate mobiles in {model._meta.label}: record IDs {seen[key]} and {row.pk}. ' + 'Resolve these records before migration. No records were deleted.' + ) + if mobile is not None: + seen[key] = row.pk + if mobile != row.mobile: + row.mobile = mobile + updates.append(row) + return updates + + +def check_and_normalize_mobiles(apps, schema_editor): + alias = schema_editor.connection.alias + customer = apps.get_model('customer', 'Customer') + user = apps.get_model('cuser', 'CUser') + role = apps.get_model('cuser', 'CuserRole') + # Audit both tables before writing. A collision never silently merges people. + customer_updates = normalized_rows(customer, alias, per_workspace=True) + user_updates = normalized_rows(user, alias) + role_updates = [] + for row in role.objects.using(alias).only('pk', 'mobile').iterator(): + normalized = canonical_mobile(row.mobile) + if normalized != row.mobile: + row.mobile = normalized + role_updates.append(row) + customer.objects.using(alias).bulk_update(customer_updates, ['mobile'], batch_size=500) + user.objects.using(alias).bulk_update(user_updates, ['mobile'], batch_size=500) + role.objects.using(alias).bulk_update(role_updates, ['mobile'], batch_size=500) + + +class Migration(migrations.Migration): + dependencies = [('customer', '0007_alter_customer_name_family'), ('cuser', '0013_alter_cuser_name_family')] + operations = [ + migrations.RunPython(check_and_normalize_mobiles, migrations.RunPython.noop), + migrations.AddConstraint( + model_name='customer', + constraint=models.UniqueConstraint(fields=('sys_ws', 'mobile'), name='customer_workspace_mobile_unique'), + ), + ] diff --git a/apps/customer/models.py b/apps/customer/models.py index 5838568..b018c7c 100644 --- a/apps/customer/models.py +++ b/apps/customer/models.py @@ -3,6 +3,7 @@ from utils.base_model import BaseModel from apps.workspace.models import City as CityModel, Workspace as WorkspaceModel from apps.cuser.models import CuserRole as CuserRoleModel from django.core.exceptions import ValidationError +from utils.validations.mobile import normalize_mobile # Create your models here. @@ -61,13 +62,24 @@ class Customer(BaseModel): class Meta: ordering = ['-created_at'] + constraints = [ + models.UniqueConstraint(fields=['sys_ws', 'mobile'], name='customer_workspace_mobile_unique'), + ] def __str__(self): return str(self.name_family) def clean(self): + super().clean() + if self.mobile is not None: + self.mobile = normalize_mobile(self.mobile) + if type(self).objects.filter(sys_ws_id=self.sys_ws_id, mobile=self.mobile).exclude(pk=self.pk).exists(): + raise ValidationError({"mobile": "مشتری با این شماره موبایل در این فضای کاری قبلاً ثبت شده است."}) if self.cuser_role and not self.cuser_role.role_type.role_name_en == 'workspace_customer': raise ValidationError("cuser_role type must be customer") + if self.cuser_role and (self.cuser_role.sys_ws_id != self.sys_ws_id or not self.cuser_role.cuser_id + or self.cuser_role.cuser.mobile != self.mobile): + raise ValidationError({'cuser_role': 'حساب پنل باید متعلق به همین شماره موبایل و فضای کاری باشد.'}) class CustomerNote(BaseModel): diff --git a/apps/customer/services.py b/apps/customer/services.py index 09f6ea3..a74f200 100644 --- a/apps/customer/services.py +++ b/apps/customer/services.py @@ -10,7 +10,9 @@ from apps.cuser.models import CuserRole as CuserRoleModel from rest_framework import exceptions from utils.get_user_workspace import get_user_workspace, get_user_workspace_uuid from utils.get_cuser_role import get_cuser_role -from django.db import transaction +from django.db import transaction, IntegrityError +from django.core.exceptions import ValidationError as DjangoValidationError +from utils.validations.mobile import normalize_mobile from django.contrib.auth import get_user_model import uuid import random @@ -20,11 +22,20 @@ CUser = get_user_model() @transaction.atomic def customer_panel_access(request, mobile: int, customer_id: int, status: bool) -> CuserRoleModel: - try: workspace = get_user_workspace(request) customer = CustomerModel.objects.get(id=customer_id, sys_ws=workspace) - + mobile = normalize_mobile(mobile) + if customer.mobile != mobile: + raise exceptions.ValidationError({'mobile': 'شماره موبایل باید با شماره مشتری یکسان باشد.'}) + # Disabling access must not create an unrelated account or a new role. + if not status: + if customer.cuser_role_id: + if customer.cuser_role.sys_ws_id != workspace.pk: + raise exceptions.ValidationError('اتصال پنل متعلق به این فضای کاری نیست؛ ابتدا اتصال را اصلاح کنید.') + customer.cuser_role.customer_panel = False + customer.cuser_role.save(update_fields=['customer_panel']) + return customer.cuser_role try: cuser_obj = CUser.objects.get(mobile=mobile) except CUser.DoesNotExist: @@ -36,29 +47,29 @@ def customer_panel_access(request, mobile: int, customer_id: int, status: bool) is_active=True, ) - try: - cuser_role_obj = CuserRoleModel.objects.get(cuser=cuser_obj, role_type_id=3) - cuser_role_obj.customer_panel = status - cuser_role_obj.save() - except CuserRoleModel.DoesNotExist: - cuser_role_obj = CuserRoleModel.objects.create( - cuser=cuser_obj, - role_type_id=3, - name_family=customer.name_family, - mobile=mobile, - mobile_verify=True, - is_active=True, - customer_panel=status, - sys_ws=workspace - ) - - if cuser_role_obj: - customer.cuser_role = cuser_role_obj - customer.save() - return cuser_role_obj - else: - raise Exception - + from apps.cuser.models import RoleType + if not cuser_obj.is_active: + raise exceptions.PermissionDenied('حساب کاربری مربوط به این شماره غیرفعال است.') + role_type = RoleType.objects.get(role_name_en='workspace_customer') + cuser_role_obj, _ = CuserRoleModel.objects.get_or_create( + cuser=cuser_obj, role_type=role_type, sys_ws=workspace, + defaults={'name_family': customer.name_family, 'mobile': mobile, + 'mobile_verify': True, 'is_active': True, 'customer_panel': True}, + ) + if CustomerModel.objects.filter(cuser_role=cuser_role_obj, sys_ws=workspace).exclude(pk=customer.pk).exists(): + raise exceptions.ValidationError({'mobile': 'این شماره قبلاً به پنل مشتری دیگری متصل شده است.'}) + cuser_role_obj.customer_panel = True + cuser_role_obj.save(update_fields=['customer_panel']) + customer.cuser_role = cuser_role_obj + customer.full_clean() + customer.save() + return cuser_role_obj + except exceptions.APIException: + raise + except DjangoValidationError as exp: + raise exceptions.ValidationError(exp.message_dict) + except IntegrityError: + raise exceptions.ValidationError({'mobile': 'این شماره موبایل قبلاً ثبت شده است.'}) except Exception as exp: message = f'Error Create or Update Customer panel accsess: {exp}' raise exceptions.ValidationError({'error': message}) @@ -104,6 +115,7 @@ def delete_shive_ashenaei(shive_id: int, workspace_uuid: str) -> ShiveAshenaeiMo raise exceptions.ValidationError({'error': message}) +@transaction.atomic def create_customer(name_family: str , mobile: int, phone: int, address: str, city: int, shive_ashenaei: int, responsible_group: int, cuser_role: int = None, workspace: WorkspaceModel = None) -> CustomerModel: try: @@ -122,39 +134,48 @@ def create_customer(name_family: str , mobile: int, phone: int, address: str, ci customer_obj.full_clean() customer_obj.save() return customer_obj + except DjangoValidationError as exp: + raise exceptions.ValidationError(exp.message_dict) + except IntegrityError: + raise exceptions.ValidationError({'mobile': 'مشتری با این شماره موبایل قبلاً ثبت شده است.'}) except Exception as exp: message = f'Error Creating Customer: {exp}' raise exceptions.ValidationError({'error': message}) -def update_customer(customer_id: int, name_family: str = None , mobile: int = None, phone: int = None, address: str = None, +def set_customer_mobile(customer, mobile): + if mobile is None: + return + normalized = normalize_mobile(mobile) + if normalized != customer.mobile and customer.cuser_role_id: + role = customer.cuser_role + if role.sys_ws_id == customer.sys_ws_id: + role.customer_panel = False + role.save(update_fields=['customer_panel']) + customer.cuser_role = None + customer.mobile = normalized + + +@transaction.atomic +def update_customer(customer_id: int, name_family: str = None, mobile: int = None, phone: int = None, address: str = None, city: int = None, shive_ashenaei: int = None, responsible_group: int = None, cuser_role: int = None, workspace_uuid: str = None) -> CustomerModel: try: - customer_obj = CustomerModel.objects.get(id=customer_id, sys_ws__uuid=workspace_uuid) - - if name_family is not None: - customer_obj.name_family = name_family - if mobile is not None: - customer_obj.mobile = mobile - if phone is not None: - customer_obj.phone = phone - if address is not None: - customer_obj.address = address - if city is not None: - customer_obj.city = city - if shive_ashenaei is not None: - customer_obj.shive_ashenaei = shive_ashenaei - if responsible_group is not None: - customer_obj.responsible_group = responsible_group - if cuser_role is not None: - customer_obj.cuser_role = cuser_role - - customer_obj.full_clean() - customer_obj.save() - return customer_obj - except Exception as exp: - message = f'Error Creating Customer: {exp}' - raise exceptions.ValidationError({'error': message}) + customer = CustomerModel.objects.select_for_update().get(id=customer_id, sys_ws__uuid=workspace_uuid) + set_customer_mobile(customer, mobile) + fields = {'name_family': name_family, 'phone': phone, 'address': address, 'city': city, + 'shive_ashenaei': shive_ashenaei, 'responsible_group': responsible_group, 'cuser_role': cuser_role} + for field, value in fields.items(): + if value is not None: + setattr(customer, field, value) + customer.full_clean() + customer.save() + return customer + except DjangoValidationError as error: + raise exceptions.ValidationError(error.message_dict) + except IntegrityError: + raise exceptions.ValidationError({'mobile': 'مشتری با این شماره موبایل قبلاً ثبت شده است.'}) + except CustomerModel.DoesNotExist: + raise exceptions.NotFound('مشتری در این فضای کاری پیدا نشد.') def delete_customer(customer_id: int, workspace_uuid: str = None) -> CustomerModel: @@ -165,6 +186,7 @@ def delete_customer(customer_id: int, workspace_uuid: str = None) -> CustomerMod raise exceptions.ValidationError({'error': message}) +@transaction.atomic def create_customer_v2(request, name_family: str , mobile: int, phone: int, address: str, city: int, shive_ashenaei: int, responsible_group: int, cuser_role: int = None) -> CustomerModel: try: @@ -186,6 +208,10 @@ def create_customer_v2(request, name_family: str , mobile: int, phone: int, addr customer_obj.full_clean() customer_obj.save() return customer_obj + except DjangoValidationError as exp: + raise exceptions.ValidationError(exp.message_dict) + except IntegrityError: + raise exceptions.ValidationError({'mobile': 'مشتری با این شماره موبایل قبلاً ثبت شده است.'}) except Exception as exp: message = f'Error Creating Customer: {exp}' raise exceptions.ValidationError({'error': message}) diff --git a/apps/customer/views/customer.py b/apps/customer/views/customer.py index 49632ef..ca5192f 100644 --- a/apps/customer/views/customer.py +++ b/apps/customer/views/customer.py @@ -13,6 +13,17 @@ from apps.workspace.models import City as CityModel from utils.tools import inline_model_serializer, inline_serializer from utils.pagination import CustomPagination from django_filters import rest_framework as filters +from django.core.exceptions import ValidationError as DjangoValidationError +from utils.validations.mobile import normalize_mobile + + +class MobileField(serializers.CharField): + def to_internal_value(self, data): + value = super().to_internal_value(data) + try: + return normalize_mobile(value) + except DjangoValidationError: + raise serializers.ValidationError('شماره موبایل معتبر وارد کنید.') @@ -87,6 +98,7 @@ class CustomerCreateApi(APIView): permission_classes = (IsAuthenticated, custom_permissions.WorkspaceOperatorPermission) class InputSerializer(serializers.ModelSerializer): + mobile = MobileField(required=False, allow_null=True) class Meta: model = CustomerModel fields = '__all__' @@ -114,6 +126,7 @@ class CustomerUpdateApi(APIView): permission_classes = (IsAuthenticated, custom_permissions.WorkspaceOperatorPermission) class InputSerializer(serializers.ModelSerializer): + mobile = MobileField(required=False, allow_null=True) class Meta: model = CustomerModel fields = '__all__' @@ -125,7 +138,7 @@ class CustomerUpdateApi(APIView): exclude = ('sys_ws', ) def patch(self, request, customer_id: int): - serializer = self.InputSerializer(data=request.data) + serializer = self.InputSerializer(data=request.data, partial=True) serializer.is_valid(raise_exception=True) user_workspace_uuid = get_user_workspace_uuid(request=request) @@ -215,7 +228,7 @@ class CustomerCreateApiV2(APIView): class InputSerializer(serializers.Serializer): name_family = serializers.CharField(max_length=25, required=True) - mobile = serializers.IntegerField(required=True) + mobile = MobileField(required=True) phone = serializers.IntegerField(required=True) address = serializers.CharField(max_length=300, required=True) city = serializers.IntegerField(required=True) diff --git a/apps/customer/views/customer_panel.py b/apps/customer/views/customer_panel.py index 005440b..839232b 100644 --- a/apps/customer/views/customer_panel.py +++ b/apps/customer/views/customer_panel.py @@ -22,6 +22,6 @@ class CustomerPanelApi(APIView): cuser_role = customer_panel_access(request=request, **serializer.validated_data) - return Response(data={'cuser_role_id': cuser_role.id}, status=status.HTTP_200_OK) + return Response(data={'cuser_role_id': cuser_role.id if cuser_role else None}, status=status.HTTP_200_OK) diff --git a/apps/customform/admin.py b/apps/customform/admin.py index 77cc13d..7ebc57f 100644 --- a/apps/customform/admin.py +++ b/apps/customform/admin.py @@ -1,3 +1,4 @@ +from utils.admin_base import CRMModelAdmin from django.contrib import admin from apps.customform.models import ( CustomForm as CustomFormModel, @@ -7,16 +8,28 @@ from apps.customform.models import ( # Register your models here. +class CustomFormFieldInline(admin.TabularInline): + model = CustomFormFieldModel + extra = 0 + fields = ["field_name_fa","field_name_en","validator"] + readonly_fields = fields + can_delete = False + show_change_link = True + + def has_add_permission(self, request, obj=None): + return False + + @admin.register(CustomFormModel) -class CustomFormAdmin(admin.ModelAdmin): +class CustomFormAdmin(CRMModelAdmin): pass @admin.register(CustomFormDataModel) -class CustomFormDataAdmin(admin.ModelAdmin): +class CustomFormDataAdmin(CRMModelAdmin): pass @admin.register(CustomFormFieldModel) -class CustomFormFieldAdmin(admin.ModelAdmin): +class CustomFormFieldAdmin(CRMModelAdmin): pass diff --git a/apps/jwt_auth/admin.py b/apps/jwt_auth/admin.py index f79b492..fa3d145 100644 --- a/apps/jwt_auth/admin.py +++ b/apps/jwt_auth/admin.py @@ -1,17 +1,39 @@ from django.contrib import admin -from apps.jwt_auth.models import ( - JwtToken as JwtTokenModel, - CUserRoleLoginOpt as CUserRoleLoginOptModel, -) - -# Register your models here. +from apps.jwt_auth.models import JwtToken, CUserRoleLoginOpt +from utils.admin_base import CRMModelAdmin -@admin.register(JwtTokenModel) -class JwtTokenAdmin(admin.ModelAdmin): - pass +@admin.register(JwtToken) +class JwtTokenAdmin(CRMModelAdmin): + list_display = ('id', 'cuser', 'cuser_role', 'expire', 'is_blocked') + list_filter = ('is_blocked', 'cuser_role__sys_ws') + search_fields = ('cuser__username', 'cuser__mobile') + exclude = ('refresh', 'access') + sensitive_fields = ('refresh', 'access') + readonly_fields = ('cuser', 'cuser_role', 'expire', 'is_blocked') + actions = ('revoke_sessions', 'export_selected') + + @admin.action(description='ابطال نشست‌های انتخاب‌شده', permissions=['change']) + def revoke_sessions(self, request, queryset): + count = queryset.update(is_blocked=True) + for obj in queryset: + self.log_change(request, obj, 'نشست از پنل مدیریت ابطال شد.') + self.message_user(request, f'{count} نشست ابطال شد.') + + def has_add_permission(self, request): + return False -@admin.register(CUserRoleLoginOptModel) -class CUserRoleLoginOptAdmin(admin.ModelAdmin): - pass +@admin.register(CUserRoleLoginOpt) +class CUserRoleLoginOptAdmin(CRMModelAdmin): + list_display = ('id', 'cuser', 'cuser_role', 'expire') + search_fields = ('cuser__username', 'cuser__mobile') + exclude = ('uuid',) + sensitive_fields = ('uuid',) + readonly_fields = ('cuser', 'cuser_role', 'expire') + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False diff --git a/apps/jwt_auth/authentication.py b/apps/jwt_auth/authentication.py new file mode 100644 index 0000000..e00a5d8 --- /dev/null +++ b/apps/jwt_auth/authentication.py @@ -0,0 +1,19 @@ +from rest_framework.exceptions import AuthenticationFailed +from rest_framework_simplejwt.authentication import JWTAuthentication + +from apps.jwt_auth.models import JwtToken +from apps.jwt_auth.session import validate_session_claims + + +class SessionJWTAuthentication(JWTAuthentication): + """A signed JWT is usable only while its recorded session remains active.""" + + def get_user(self, validated_token): + user = super().get_user(validated_token) + session = JwtToken.objects.select_related('cuser_role__role_type', 'cuser_role__sys_ws', 'cuser_role__cuser').filter( + access=str(validated_token), cuser=user, is_blocked=False, + ).first() + if not session: + raise AuthenticationFailed('نشست شما معتبر نیست. لطفاً دوباره وارد شوید.', code='session_invalid') + validate_session_claims(session, validated_token) + return user diff --git a/apps/jwt_auth/migrations/0093_login_option_uuid_default.py b/apps/jwt_auth/migrations/0093_login_option_uuid_default.py new file mode 100644 index 0000000..cf1d466 --- /dev/null +++ b/apps/jwt_auth/migrations/0093_login_option_uuid_default.py @@ -0,0 +1,12 @@ +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [('jwt_auth', '0092_alter_cuserroleloginopt_uuid')] + operations = [ + migrations.AlterField( + model_name='cuserroleloginopt', name='uuid', + field=models.UUIDField(default=uuid.uuid4, unique=True), + ), + ] diff --git a/apps/jwt_auth/models.py b/apps/jwt_auth/models.py index ab84c9d..4681b73 100644 --- a/apps/jwt_auth/models.py +++ b/apps/jwt_auth/models.py @@ -22,4 +22,4 @@ class CUserRoleLoginOpt(models.Model): cuser_role = models.ForeignKey(CuserRoleModel, on_delete=models.CASCADE) cuser = models.ForeignKey(CUser, null=True, blank=True, default=None, on_delete=models.CASCADE) expire = models.DateTimeField(null=False, blank=False) - uuid = models.UUIDField(default=uuid.uuid4(), unique=True, null=False, blank=False) + uuid = models.UUIDField(default=uuid.uuid4, unique=True, null=False, blank=False) diff --git a/apps/jwt_auth/services.py b/apps/jwt_auth/services.py index 7ec9ace..abcb33b 100644 --- a/apps/jwt_auth/services.py +++ b/apps/jwt_auth/services.py @@ -1,110 +1,99 @@ -import uuid -import random +import secrets +from datetime import timedelta + from django.contrib.auth import get_user_model -from apps.jwt_auth.models import CUserRoleLoginOpt -from apps.cuser.models import CuserRole as CuserRoleModel, SmsOpt as SmsOptModel +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction from django.utils import timezone from rest_framework import exceptions -import requests -from datetime import timedelta + +from apps.cuser.models import CuserRole, SmsOpt +from apps.jwt_auth.models import CUserRoleLoginOpt +from utils.services.sms import DefaultSmsProvider +from utils.validations.mobile import normalize_mobile CUser = get_user_model() -def login_by_username(username: str, password: str) -> CUserRoleLoginOpt: + +class SmsDeliveryUnavailable(exceptions.APIException): + status_code = 503 + default_detail = 'ارسال پیامک انجام نشد. لطفاً کمی بعد دوباره تلاش کنید.' + default_code = 'sms_failed' + + +def eligible_roles(user, customer_only=False): + roles = CuserRole.objects.select_related('role_type', 'sys_ws', 'cuser').filter( + cuser=user, is_active=True, mobile_verify=True, sys_ws__is_active=True, + ) + if customer_only: + roles = roles.filter(role_type__role_name_en='workspace_customer', customer_panel=True) + return [role for role in roles if + (not role.sys_ws.expire or role.sys_ws.expire > timezone.now()) and + (role.role_type.role_name_en != 'workspace_customer' or role.customer_panel is True)] + + +def create_login_options(user, roles): + CUserRoleLoginOpt.objects.filter(cuser=user).delete() + options = [] + for role in roles: + options.append(CUserRoleLoginOpt.objects.create( + cuser=user, cuser_role=role, expire=timezone.now() + timedelta(minutes=5), + )) + return options + + +@transaction.atomic +def login_by_username(username: str, password: str): + user = CUser.objects.select_for_update().filter(username=username).first() + if not user or not user.check_password(password): + raise exceptions.ValidationError({'error': 'نام کاربری یا رمز عبور اشتباه است.'}) + if not user.is_active: + raise exceptions.PermissionDenied('حساب کاربری شما غیرفعال است.') + roles = eligible_roles(user) + if not roles: + raise exceptions.PermissionDenied('نقش یا دسترسی فعال برای این حساب وجود ندارد.') + return create_login_options(user, roles) + + +def mobile_user(mobile): try: - print(username) - user = CUser.objects.get(username=username) - - if user and user.check_password(password): - - cuser_roles = CuserRoleModel.objects.filter(cuser=user) - CUserRoleLoginOpt.objects.filter(cuser_role__cuser=user).delete() - - if cuser_roles: - for user_role_obj in cuser_roles: - - if not (user_role_obj.is_active and user_role_obj.mobile_verify): - raise exceptions.ValidationError({"error": 'Not a valid user'}) - - expire_datetime = timezone.datetime.now() + timezone.timedelta(minutes=5) - - user_role_opt = CUserRoleLoginOpt() - user_role_opt.cuser_role = user_role_obj - user_role_opt.cuser = user_role_obj.cuser - user_role_opt.expire = expire_datetime - user_role_opt.uuid = uuid.uuid4() - user_role_opt.save() - - login_opts = CUserRoleLoginOpt.objects.filter(cuser_role__cuser=user) - return login_opts - - else: - raise exceptions.ValidationError({'error': "User not have role"}) - else: - raise exceptions.ValidationError({'error': "Username or password is not correct"}) - - except CUser.DoesNotExist: - raise exceptions.ValidationError({'error': "Username or password is not correct"}) + mobile = normalize_mobile(mobile) + except DjangoValidationError as error: + raise exceptions.ValidationError(error.message_dict) + user = CUser.objects.filter(mobile=mobile).first() + if not user: + raise exceptions.ValidationError({'mobile': 'کاربری با این شماره موبایل ثبت نشده است.'}) + if not user.is_active: + raise exceptions.PermissionDenied('حساب کاربری شما غیرفعال است.') + roles = eligible_roles(user, customer_only=True) + if not roles: + raise exceptions.PermissionDenied('دسترسی شما به پنل مشتریان فعال نیست.') + return user, roles +@transaction.atomic def login_by_mobile(mobile: int): - try: - - exists_cuser = CUser.objects.get(mobile=mobile) - - random_number = random.randint(100000, 999999) + user, _ = mobile_user(mobile) + # Serialize code requests for this account, retaining the old valid code if + # the provider refuses the new message. + CUser.objects.select_for_update().get(pk=user.pk) + code = secrets.randbelow(900000) + 100000 + if not DefaultSmsProvider().send_sms(str(user.mobile), f'کد ورود شما: {code}'): + raise SmsDeliveryUnavailable() + SmsOpt.objects.filter(mobile=user.mobile).delete() + SmsOpt.objects.create(mobile=user.mobile, opt=code, expire=timezone.now() + timedelta(minutes=3)) + return user.mobile - try: - SmsOptModel.objects.filter(mobile=mobile).delete() - except Exception as exp: - pass - - expire_time = timezone.now() + timedelta(minutes=3) - opt_code = SmsOptModel(mobile=mobile, opt=random_number, expire=expire_time) - opt_code.save() - - sms_text = f' کد ورود شما: {random_number}' - - payload = { - 'USERNAME': 'vsms7937', - 'PASSWORD': '335577775533', - 'FROM': '30005367', - 'TO': int(mobile), - 'TEXT': f'{sms_text}' - } - r = requests.get('http://www.0098sms.com/sendsmslink.aspx', params=payload) - - except Exception as exp: - raise exceptions.ValidationError({'error': "mobile not found"}) - - -def verify_mobile_and_get_token(mobile: int, opt: int) -> CUserRoleLoginOpt: - try: - time_now = timezone.now() - opt_obj = SmsOptModel.objects.filter(mobile=mobile, opt=opt, expire__gte=time_now)[0] - - if opt_obj: - cuser_roles = CuserRoleModel.objects.filter(cuser__mobile=mobile, is_active=True) - CUserRoleLoginOpt.objects.filter(cuser_role__in=cuser_roles).delete() - - if cuser_roles: - for user_role_obj in cuser_roles: - expire_datetime = timezone.datetime.now() + timezone.timedelta(minutes=5) - - user_role_opt = CUserRoleLoginOpt() - user_role_opt.cuser_role = user_role_obj - user_role_opt.cuser = user_role_obj.cuser - user_role_opt.expire = expire_datetime - user_role_opt.uuid = uuid.uuid4() - user_role_opt.save() - - login_opts = CUserRoleLoginOpt.objects.filter(cuser_role__in=cuser_roles) - SmsOptModel.objects.filter(mobile=mobile).delete() - return login_opts - except Exception as exp: - try: - SmsOptModel.objects.filter(mobile=mobile).delete() - except Exception as exp: - pass - raise exceptions.ValidationError({'error': "cannot verify this mobile and opt code:"+str(exp)}) +@transaction.atomic +def verify_mobile_and_get_token(mobile: int, opt: int): + user, roles = mobile_user(mobile) + CUser.objects.select_for_update().get(pk=user.pk) + code = SmsOpt.objects.select_for_update().filter( + mobile=user.mobile, opt=opt, expire__gt=timezone.now(), + ).first() + if not code: + raise exceptions.ValidationError({'opt': 'کد تأیید اشتباه است یا اعتبار آن تمام شده است.'}) + options = create_login_options(user, roles) + SmsOpt.objects.filter(mobile=user.mobile).delete() + return options diff --git a/apps/jwt_auth/session.py b/apps/jwt_auth/session.py new file mode 100644 index 0000000..b1eaef2 --- /dev/null +++ b/apps/jwt_auth/session.py @@ -0,0 +1,49 @@ +from datetime import datetime, timezone as datetime_timezone + +from django.utils import timezone +from django.db import transaction +from rest_framework.exceptions import AuthenticationFailed +from rest_framework_simplejwt.tokens import RefreshToken + +from apps.jwt_auth.models import JwtToken + + +def validate_role_access(role): + if not (role.cuser and role.cuser.is_active and role.is_active and role.mobile_verify): + raise AuthenticationFailed('حساب کاربری یا نقش شما غیرفعال است. دوباره وارد شوید.', code='session_invalid') + workspace = role.sys_ws + if not workspace or not workspace.is_active or (workspace.expire and workspace.expire <= timezone.now()): + raise AuthenticationFailed('دسترسی فضای کاری غیرفعال یا منقضی شده است.', code='session_invalid') + if role.role_type.role_name_en == 'workspace_customer' and role.customer_panel is not True: + raise AuthenticationFailed('دسترسی شما به پنل مشتریان فعال نیست.', code='session_invalid') + + +@transaction.atomic +def issue_tokens(role, session=None): + validate_role_access(role) + if session is None: + session = JwtToken.objects.create(cuser=role.cuser, cuser_role=role, refresh='') + refresh = RefreshToken.for_user(role.cuser) + refresh['workspace_uuid'] = str(role.sys_ws.uuid) + refresh['cuser_role_uuid'] = str(role.uuid) + refresh['user_uuid'] = str(role.cuser.uuid) + refresh['session_id'] = session.pk + data = {'refresh': str(refresh), 'access': str(refresh.access_token)} + session.refresh = data['refresh'] + session.access = data['access'] + session.expire = datetime.fromtimestamp(refresh['exp'], tz=datetime_timezone.utc) + session.save() + return data + + +def validate_session_claims(session, token): + if session.expire and session.expire <= timezone.now(): + raise AuthenticationFailed('نشست شما منقضی شده است. دوباره وارد شوید.', code='session_invalid') + role = session.cuser_role + validate_role_access(role) + expected = {'user_id': session.cuser_id, 'user_uuid': str(role.cuser.uuid), + 'cuser_role_uuid': str(role.uuid), 'workspace_uuid': str(role.sys_ws.uuid)} + if token.get('session_id') is not None: + expected['session_id'] = session.pk + if role.cuser_id != session.cuser_id or any(token.get(key) != value for key, value in expected.items()): + raise AuthenticationFailed('نشست شما معتبر نیست. دوباره وارد شوید.', code='session_invalid') diff --git a/apps/jwt_auth/test_regressions.py b/apps/jwt_auth/test_regressions.py new file mode 100644 index 0000000..0eb06ba --- /dev/null +++ b/apps/jwt_auth/test_regressions.py @@ -0,0 +1,453 @@ +"""Authentication, duplicate mobiles and ordering integration regressions.""" +from datetime import timedelta +from io import BytesIO +from unittest.mock import Mock, patch + +from django.contrib import admin +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.utils import timezone +from openpyxl import load_workbook +from rest_framework.test import APITestCase +from rest_framework_simplejwt.tokens import RefreshToken + +from apps.cuser.cuser_model import CUser +from apps.cuser.models import CuserRole, RoleType, SmsOpt +from apps.customer.models import Customer, ResponsibleGroup, ShiveAshenaei +from apps.jwt_auth.models import CUserRoleLoginOpt, JwtToken +from apps.jwt_auth.session import issue_tokens +from apps.shop.admin import ProductAdmin, ProductCategoryAdmin, excel_response +from apps.shop.models import Product, ProductCategory, Order, OrderItem +from apps.workspace.models import City, Workspace +from utils.services.sms import DosefrNavadHashtSMS +from utils.validations.mobile import normalize_mobile + + +class RegressionFixtures(APITestCase): + @classmethod + def setUpTestData(cls): + cls.workspace = Workspace.objects.create(name='Test', username='test') + cls.other_workspace = Workspace.objects.create(name='Other', username='other') + cls.user = CUser.objects.create_user('client', 'password', mobile=9123456789, name_family='Client', is_active=True) + cls.customer_type = RoleType.objects.create(role_name='Customer', role_name_en='workspace_customer') + cls.operator_type = RoleType.objects.create(role_name='Operator', role_name_en='workspace_operator') + cls.admin_type = RoleType.objects.create(role_name='Admin', role_name_en='workspace_admin') + cls.role = CuserRole.objects.create(cuser=cls.user, role_type=cls.customer_type, sys_ws=cls.workspace, + name_family='Client', mobile=cls.user.mobile, is_active=True, + mobile_verify=True, customer_panel=True) + cls.operator = CuserRole.objects.create(cuser=cls.user, role_type=cls.operator_type, sys_ws=cls.workspace, + name_family='Operator', mobile=cls.user.mobile, is_active=True, mobile_verify=True) + cls.customer = Customer.objects.create(name_family='Client', mobile=cls.user.mobile, cuser_role=cls.role, sys_ws=cls.workspace) + cls.city = City.objects.create(name='City') + cls.group = ResponsibleGroup.objects.create(name='Group', sys_ws=cls.workspace) + cls.shive = ShiveAshenaei.objects.create(name='Web', sys_ws=cls.workspace) + + def use_role(self, role=None): + tokens = issue_tokens(role or self.role) + self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + tokens['access']) + return tokens + + def post(self, path, data): + return self.client.post('/auth/' + path, data, format='json') + + def otp(self, expired=False): + return SmsOpt.objects.create(mobile=self.user.mobile, opt=123456, + expire=timezone.now() + timedelta(minutes=-1 if expired else 3)) + + +class AuthRegressionTests(RegressionFixtures): + def test_unknown_mobile_has_useful_400(self): + response = self.post('login/mobile/', {'mobile': '09999999999'}) + self.assertEqual(response.status_code, 400) + self.assertIn('ثبت نشده', str(response.data)) + + def test_invalid_mobile_has_400(self): + self.assertEqual(self.post('login/mobile/', {'mobile': '123'}).status_code, 400) + + def test_disabled_customer_panel_has_403_and_sends_no_sms(self): + self.role.customer_panel = False + self.role.save() + with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms') as send: + response = self.post('login/mobile/', {'mobile': self.user.mobile}) + self.assertEqual(response.status_code, 403) + self.assertIn('پنل مشتریان', str(response.data)) + send.assert_not_called() + + def test_inactive_user_has_403(self): + self.user.is_active = False + self.user.save() + self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 403) + + def test_sms_failure_has_503_and_preserves_old_otp(self): + old = self.otp() + with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=False): + response = self.post('login/mobile/', {'mobile': self.user.mobile}) + self.assertEqual(response.status_code, 503) + self.assertIn('ارسال پیامک', str(response.data)) + self.assertTrue(SmsOpt.objects.filter(pk=old.pk).exists()) + + def test_successful_sms_saves_only_one_code(self): + self.otp() + with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=True): + response = self.post('login/mobile/', {'mobile': '+98 912-345-6789'}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['result']['mobile'], self.user.mobile) + self.assertEqual(SmsOpt.objects.count(), 1) + self.assertGreaterEqual(SmsOpt.objects.get().opt, 100000) + + def test_wrong_otp_preserves_valid_code(self): + old = self.otp() + self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': 654321}).status_code, 400) + self.assertTrue(SmsOpt.objects.filter(pk=old.pk).exists()) + + def test_expired_otp_rejected(self): + self.otp(expired=True) + self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': 123456}).status_code, 400) + + def test_otp_is_single_use_and_roles_have_distinct_options(self): + CuserRole.objects.create(cuser=self.user, role_type=self.customer_type, sys_ws=self.other_workspace, + name_family='Other', mobile=self.user.mobile, is_active=True, mobile_verify=True, customer_panel=True) + self.otp() + response = self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': 123456}) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 2) + self.assertNotEqual(response.data[0]['uuid'], response.data[1]['uuid']) + self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': 123456}).status_code, 400) + + def test_bad_password_has_400(self): + self.assertEqual(self.post('login/username/', {'username': 'client', 'password': 'wrong'}).status_code, 400) + + def test_public_login_ignores_stale_authorization(self): + self.client.credentials(HTTP_AUTHORIZATION='Bearer invalid') + response = self.post('login/username/', {'username': 'client', 'password': 'password'}) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 2) + + def test_option_expiry_and_replay_have_401(self): + option = CUserRoleLoginOpt.objects.create(cuser=self.user, cuser_role=self.role, expire=timezone.now()-timedelta(seconds=1)) + self.assertEqual(self.post('token/', {'uuid_opt': str(option.uuid)}).status_code, 401) + option.expire = timezone.now()+timedelta(minutes=5) + option.save() + result = self.post('token/', {'uuid_opt': str(option.uuid)}) + self.assertEqual(result.status_code, 200) + self.assertEqual(self.post('token/', {'uuid_opt': str(option.uuid)}).status_code, 401) + self.assertEqual(JwtToken.objects.count(), 1) + + def test_anonymous_info_has_401(self): + self.assertEqual(self.client.get('/cuser/info/').status_code, 401) + + def test_signed_but_unrecorded_access_is_rejected(self): + refresh = RefreshToken.for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + str(refresh.access_token)) + self.assertEqual(self.client.get('/cuser/info/').status_code, 401) + + def test_blocked_access_and_refresh_are_rejected(self): + tokens = self.use_role() + JwtToken.objects.update(is_blocked=True) + self.assertEqual(self.client.get('/cuser/info/').status_code, 401) + self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401) + + def test_missing_session_rejects_access_and_refresh(self): + tokens = self.use_role() + JwtToken.objects.all().delete() + self.assertEqual(self.client.get('/cuser/info/').status_code, 401) + self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401) + + def test_refresh_rotates_and_retains_workspace(self): + tokens = self.use_role(self.operator) + response = self.post('token/refresh/', {'refresh': tokens['refresh']}) + self.assertEqual(response.status_code, 200) + self.assertNotEqual(response.data['refresh'], tokens['refresh']) + self.assertEqual(RefreshToken(response.data['refresh'])['workspace_uuid'], str(self.workspace.uuid)) + self.assertEqual(JwtToken.objects.count(), 1) + self.assertGreater(JwtToken.objects.get().expire, timezone.now()) + self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401) + self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + response.data['access']) + self.assertEqual(self.client.get('/cuser/info/').status_code, 200) + + def test_disabling_panel_revokes_both_tokens(self): + tokens = self.use_role() + self.role.customer_panel = False + self.role.save() + self.assertEqual(self.client.get('/cuser/info/').status_code, 401) + self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401) + + def test_expired_workspace_revokes_both_tokens(self): + tokens = self.use_role() + self.workspace.expire = timezone.now()-timedelta(seconds=1) + self.workspace.save() + self.assertEqual(self.client.get('/cuser/info/').status_code, 401) + self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401) + + def test_logout_revokes_session_without_valid_access(self): + tokens = self.use_role() + self.client.credentials(HTTP_AUTHORIZATION='Bearer expired') + self.assertEqual(self.post('logout/', {'refresh': tokens['refresh']}).status_code, 204) + self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401) + self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + tokens['access']) + self.assertEqual(self.client.get('/cuser/info/').status_code, 401) + self.assertEqual(self.post('logout/', {'refresh': tokens['refresh']}).status_code, 204) + + def test_user_duplicate_formats_are_rejected(self): + for mobile in ['09123456789', '+98 912-345-6789', '۰۹۱۲۳۴۵۶۷۸۹', '00989123456789']: + with self.subTest(mobile=mobile), self.assertRaises(ValidationError): + CUser.objects.create_user('duplicate', 'password', mobile=mobile) + self.assertEqual(CUser.objects.count(), 1) + + def test_customer_duplicate_has_database_constraint(self): + with self.assertRaises(IntegrityError), transaction.atomic(): + Customer.objects.create(name_family='Duplicate', mobile=self.user.mobile, sys_ws=self.workspace) + Customer.objects.create(name_family='Other', mobile=self.user.mobile, sys_ws=self.other_workspace) + Customer.objects.create(name_family='Null1', sys_ws=self.workspace) + Customer.objects.create(name_family='Null2', sys_ws=self.workspace) + + def test_create_customer_v2_duplicate_has_mobile_error(self): + self.use_role(self.operator) + response = self.client.post('/customer/v2/create/', { + 'name_family': 'Duplicate', 'mobile': '+98 912-345-6789', 'phone': 2133333333, + 'address': 'Address', 'city': self.city.pk, 'shive_ashenaei': self.shive.pk, 'responsible_group': self.group.pk, + }, format='json') + self.assertEqual(response.status_code, 400) + self.assertIn('mobile', response.data) + self.assertEqual(Customer.objects.count(), 1) + + def test_partial_customer_update_rejects_duplicate_and_allows_same_mobile(self): + other = Customer.objects.create(name_family='Other', mobile=9333333333, sys_ws=self.workspace) + self.use_role(self.operator) + response = self.client.patch(f'/customer/update/{other.pk}/', {'mobile': '09123456789'}, format='json') + self.assertEqual(response.status_code, 400) + self.assertIn('mobile', response.data) + response = self.client.patch(f'/customer/update/{self.customer.pk}/', {'mobile': self.user.mobile}, format='json') + self.assertEqual(response.status_code, 200) + + def test_panel_link_is_scoped_to_workspace(self): + other_operator = CuserRole.objects.create(cuser=self.user, role_type=self.operator_type, sys_ws=self.other_workspace, + name_family='Operator2', mobile=self.user.mobile, is_active=True, mobile_verify=True) + other_customer = Customer.objects.create(name_family='Other', mobile=self.user.mobile, sys_ws=self.other_workspace) + self.use_role(other_operator) + response = self.client.post('/customer/customer-panel/', {'customer_id': other_customer.pk, 'mobile': self.user.mobile, 'status': True}, format='json') + self.assertEqual(response.status_code, 200) + other_customer.refresh_from_db() + self.assertNotEqual(other_customer.cuser_role_id, self.role.pk) + self.assertEqual(other_customer.cuser_role.sys_ws_id, self.other_workspace.pk) + + def test_disable_panel_without_link_does_not_create_user(self): + other = Customer.objects.create(name_family='Other', mobile=9333333333, sys_ws=self.workspace) + self.use_role(self.operator) + response = self.client.post('/customer/customer-panel/', {'customer_id': other.pk, 'mobile': other.mobile, 'status': False}, format='json') + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data['cuser_role_id']) + self.assertEqual(CUser.objects.count(), 1) + + def test_provider_checks_body_and_handles_timeout(self): + import requests + with patch('utils.services.sms.requests.get', return_value=Mock(text='0')) as send: + self.assertTrue(DosefrNavadHashtSMS().send_sms(str(self.user.mobile), 'Test')) + self.assertEqual(send.call_args.kwargs['timeout'], 10) + with patch('utils.services.sms.requests.get', return_value=Mock(text='error')): + self.assertFalse(DosefrNavadHashtSMS().send_sms(str(self.user.mobile), 'Test')) + with patch('utils.services.sms.requests.get', side_effect=requests.Timeout): + self.assertFalse(DosefrNavadHashtSMS().send_sms(str(self.user.mobile), 'Test')) + + def test_iranian_mobile_prefix_is_not_accidentally_removed(self): + self.assertEqual(normalize_mobile('9891234567'), 9891234567) + + def test_logout_during_refresh_rotation_revokes_new_tokens(self): + tokens = self.use_role() + rotated = self.post('token/refresh/', {'refresh': tokens['refresh']}) + self.assertEqual(rotated.status_code, 200) + self.assertEqual(self.post('logout/', {'refresh': tokens['refresh']}).status_code, 204) + self.assertEqual(self.post('token/refresh/', {'refresh': rotated.data['refresh']}).status_code, 401) + self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + rotated.data['access']) + self.assertEqual(self.client.get('/cuser/info/').status_code, 401) + + def test_mobile_change_disconnects_old_panel_and_revokes_old_session(self): + tokens = issue_tokens(self.role) + self.use_role(self.operator) + response = self.client.patch(f'/customer/update/{self.customer.pk}/', {'mobile': '09333333333'}, format='json') + self.assertEqual(response.status_code, 200) + self.customer.refresh_from_db() + self.role.refresh_from_db() + self.assertIsNone(self.customer.cuser_role_id) + self.assertFalse(self.role.customer_panel) + self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401) + + def test_duplicate_migration_audits_before_changing_any_data(self): + import importlib + from django.apps import apps + from django.db import connection + duplicate = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace) + migration = importlib.import_module('apps.customer.migrations.0008_customer_mobile_unique') + with self.assertRaises(RuntimeError): + migration.check_and_normalize_mobiles(apps, Mock(connection=connection)) + duplicate.refresh_from_db() + self.assertEqual(duplicate.mobile, 989123456789) + + def test_migration_normalizes_legacy_country_prefix(self): + import importlib + from django.apps import apps + from django.db import connection + legacy = Customer.objects.create(name_family='Legacy', mobile=989333333333, sys_ws=self.workspace) + migration = importlib.import_module('apps.customer.migrations.0008_customer_mobile_unique') + migration.check_and_normalize_mobiles(apps, Mock(connection=connection)) + legacy.refresh_from_db() + self.assertEqual(legacy.mobile, 9333333333) + + def test_customer_excel_import_reports_duplicate_and_rolls_back_all_rows(self): + import pandas as pd + self.city.ostan = City.objects.create(name='Province', is_ostan=True) + self.city.save() + role = CuserRole.objects.create(cuser=self.user, role_type=self.admin_type, sys_ws=self.workspace, + name_family='Admin', mobile=self.user.mobile, is_active=True, mobile_verify=True) + self.use_role(role) + rows = [{'name': 'New', 'mobile': 9333333333, 'phone': None, 'address': None, + 'ostan': 'Province', 'shar': 'City', 'id_mantaghe': self.group.pk}, + {'name': 'Duplicate', 'mobile': self.user.mobile, 'phone': None, 'address': None, + 'ostan': 'Province', 'shar': 'City', 'id_mantaghe': self.group.pk}] + with patch('apps.shop.views.excel_import.pd.read_excel', return_value=pd.DataFrame(rows)): + response = self.client.get('/shop/import/customer/') + self.assertEqual(response.status_code, 400) + self.assertIn('mobile', response.data['errors']) + self.assertEqual(Customer.objects.count(), 1) + + def test_anonymous_import_requests_are_rejected(self): + self.assertEqual(self.client.get('/shop/import/customer/').status_code, 401) + self.assertEqual(self.client.get('/shop/import/').status_code, 401) + + def test_recorded_session_expiry_is_enforced(self): + tokens = self.use_role() + JwtToken.objects.update(expire=timezone.now()-timedelta(seconds=1)) + self.assertEqual(self.client.get('/cuser/info/').status_code, 401) + self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401) + + def test_inactive_role_and_workspace_reject_access(self): + self.use_role() + self.role.is_active = False + self.role.save() + self.assertEqual(self.client.get('/cuser/info/').status_code, 401) + self.role.is_active = True + self.role.save() + self.workspace.is_active = False + self.workspace.save() + self.assertEqual(self.client.get('/cuser/info/').status_code, 401) + + + def test_admin_user_creation_hashes_password_and_normalizes_mobile(self): + self.user.is_staff = True + self.user.is_superuser = True + self.user.save() + self.client.force_login(self.user) + response = self.client.post('/admin/cuser/cuser/add/', { + 'username': 'new-user', 'name_family': 'New User', 'mobile': '+98 933-333-3333', + 'password1': 'Valid-Password-9320!', 'password2': 'Valid-Password-9320!', + 'is_active': 'on', 'is_staff': 'on', '_save': 'Save', + }) + self.assertEqual(response.status_code, 302) + user = CUser.objects.get(username='new-user') + self.assertEqual(user.mobile, 9333333333) + self.assertTrue(user.check_password('Valid-Password-9320!')) + self.assertNotEqual(user.password, 'Valid-Password-9320!') + + def test_admin_duplicate_user_mobile_is_a_form_error(self): + self.user.is_staff = True + self.user.is_superuser = True + self.user.save() + self.client.force_login(self.user) + response = self.client.post('/admin/cuser/cuser/add/', { + 'username': 'duplicate', 'mobile': '+98 912-345-6789', + 'password1': 'Valid-Password-9320!', 'password2': 'Valid-Password-9320!', '_save': 'Save', + }) + self.assertEqual(response.status_code, 200) + self.assertIn('mobile', response.context['adminform'].form.errors) + self.assertEqual(CUser.objects.count(), 1) + + +class OrderingRegressionTests(RegressionFixtures): + def setUp(self): + self.first = ProductCategory.objects.create(name='First', sys_ws=self.workspace, order_cat=1, excel_order=20) + self.second = ProductCategory.objects.create(name='Second', sys_ws=self.workspace, order_cat=2, excel_order=10) + self.unordered = ProductCategory.objects.create(name='Unordered', sys_ws=self.workspace) + self.a = Product.objects.create(name='A', category=self.first, sys_ws=self.workspace, price=10, order_prd=2, excel_order=1, attr={'acc_vahed': 'Unit'}) + self.b = Product.objects.create(name='B', category=self.first, sys_ws=self.workspace, price=20, order_prd=1, excel_order=2) + self.c = Product.objects.create(name='C', category=self.second, sys_ws=self.workspace, price=30, order_prd=1) + + def test_display_order_groups_categories_and_products(self): + self.assertEqual(list(ProductCategory.objects.values_list('name', flat=True)), ['First', 'Second', 'Unordered']) + self.assertEqual(list(Product.objects.values_list('name', flat=True)), ['B', 'A', 'C']) + + def test_admin_excel_independent_order_and_current_column_order(self): + model_admin = ProductAdmin(Product, admin.site) + sheet = load_workbook(BytesIO(model_admin.export_excel_order(None, Product.objects.all()).content)).active + self.assertEqual([row[2] for row in list(sheet.values)[1:]], ['C', 'A', 'B']) + sheet = load_workbook(BytesIO(model_admin.export_display_order(None, Product.objects.order_by('-name')).content)).active + self.assertEqual([row[2] for row in list(sheet.values)[1:]], ['C', 'B', 'A']) + category_admin = ProductCategoryAdmin(ProductCategory, admin.site) + sheet = load_workbook(BytesIO(category_admin.export_excel_order(None, ProductCategory.objects.all()).content)).active + self.assertEqual([row[1] for row in list(sheet.values)[1:]], ['Second', 'First', 'Unordered']) + + def test_empty_excel_order_falls_back_to_display(self): + ProductCategory.objects.update(excel_order=None) + Product.objects.update(excel_order=None) + response = ProductAdmin(Product, admin.site).export_excel_order(None, Product.objects.all()) + sheet = load_workbook(BytesIO(response.content)).active + self.assertEqual([row[2] for row in list(sheet.values)[1:]], ['B', 'A', 'C']) + + def test_excel_keeps_formula_like_names_as_text(self): + response = excel_response(('Name',), [('=1+1',)], 'test.xlsx') + cell = load_workbook(BytesIO(response.content)).active['A2'] + self.assertEqual(cell.data_type, 's') + self.assertEqual(cell.value, '=1+1') + + def test_category_api_is_ordered_and_does_not_leak_other_workspace(self): + foreign = ProductCategory.objects.create(name='Foreign', sys_ws=self.other_workspace, order_cat=0) + Product.objects.create(name='Foreign product', sys_ws=self.other_workspace, category=foreign, price=1) + self.use_role() + response = self.client.get('/shop/v2/product-category/list/') + self.assertEqual(response.status_code, 200) + self.assertEqual([row['name'] for row in response.data], ['First', 'Second']) + response = self.client.get(f'/shop/product/list/{self.first.pk}/') + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data['category_img']) + self.assertEqual([row['name'] for row in response.data['products']], ['B', 'A']) + + def test_order_excel_matches_independent_order_and_handles_empty_order(self): + order = Order.objects.create(customer=self.customer, total_price=60, factor_type=1, sys_ws=self.workspace) + for product in [self.b, self.a, self.c]: + OrderItem.objects.create(order=order, product=product, product_name=product.name, price=product.price, amount=1) + self.use_role(self.operator) + response = self.client.get(f'/shop/order/{order.pk}/operator/excel/') + self.assertEqual(response.status_code, 200) + sheet = load_workbook(BytesIO(response.content)).active + self.assertEqual([row[0] for row in list(sheet.values)[1:]], ['SecondC', 'FirstA', 'FirstB']) + OrderItem.objects.all().delete() + response = self.client.get(f'/shop/order/{order.pk}/operator/excel/') + self.assertEqual(response.status_code, 200) + self.assertEqual(load_workbook(BytesIO(response.content)).active.max_row, 1) + + def test_order_excel_requires_operator_and_matching_workspace(self): + order = Order.objects.create(customer=self.customer, total_price=0, factor_type=1, sys_ws=self.other_workspace) + self.use_role() + self.assertEqual(self.client.get(f'/shop/order/{order.pk}/operator/excel/').status_code, 403) + self.use_role(self.operator) + self.assertEqual(self.client.get(f'/shop/order/{order.pk}/operator/excel/').status_code, 400) + + def test_admin_bulk_edit_category_and_product_orders(self): + self.user.is_staff = True + self.user.is_superuser = True + self.user.save() + self.client.force_login(self.user) + for model, field, objects in [('productcategory', 'order_cat', [self.first, self.second, self.unordered]), + ('product', 'order_prd', [self.a, self.b, self.c])]: + path = f'/admin/shop/{model}/' + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + data = {'_save': 'Save', 'form-TOTAL_FORMS': len(objects), 'form-INITIAL_FORMS': len(objects), + 'form-MIN_NUM_FORMS': 0, 'form-MAX_NUM_FORMS': 1000} + for index, obj in enumerate(objects): + data.update({f'form-{index}-id': obj.pk, f'form-{index}-{field}': 10+index, + f'form-{index}-excel_order': 20-index}) + self.assertEqual(self.client.post(path, data).status_code, 302) + for index, obj in enumerate(objects): + obj.refresh_from_db() + self.assertEqual(getattr(obj, field), 10+index) + self.assertEqual(obj.excel_order, 20-index) diff --git a/apps/jwt_auth/urls.py b/apps/jwt_auth/urls.py index 87d7b64..467ac1b 100644 --- a/apps/jwt_auth/urls.py +++ b/apps/jwt_auth/urls.py @@ -4,13 +4,15 @@ from apps.jwt_auth.views import ( CustomTokenObtainPairView, LoginByUserView, LoginByMobileView, - VerifyMobileAndGetTokenView + VerifyMobileAndGetTokenView, + LogoutView, ) urlpatterns = [ + path('logout/', LogoutView.as_view(), name='logout'), path('login/username/', LoginByUserView.as_view(), name='login_by_username'), path('login/mobile/', LoginByMobileView.as_view(), name='login_by_mobile'), path('login/mobile/verify/', VerifyMobileAndGetTokenView.as_view(), name='verify_mobile'), path('token/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), path('token/refresh/', CustomTokenRefreshView.as_view(), name='token_refresh'), -] \ No newline at end of file +] diff --git a/apps/jwt_auth/views.py b/apps/jwt_auth/views.py index 2796a05..51abd35 100644 --- a/apps/jwt_auth/views.py +++ b/apps/jwt_auth/views.py @@ -1,297 +1,149 @@ -from django.shortcuts import render -from django.utils import timezone -from rest_framework.views import APIView -from rest_framework import serializers -from rest_framework.response import Response from django.db import transaction -from apps.jwt_auth.models import JwtToken as JwtTokenModel, CUserRoleLoginOpt -from apps.cuser.models import CuserRole as CuserRoleModel, RoleType as RoleTypeModel -from apps.customer.models import Customer as CustomerModel, CustomerResponsible as CustomerResponsibleModel -from rest_framework_simplejwt.tokens import RefreshToken, AccessToken, Token -from rest_framework_simplejwt.authentication import JWTAuthentication -from apps.workspace.models import Workspace as WorkspaceModel +from django.utils import timezone +from rest_framework import serializers, status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +from rest_framework_simplejwt.tokens import RefreshToken + +from apps.cuser.models import CUser, CuserRole, RoleType +from apps.jwt_auth.models import JwtToken, CUserRoleLoginOpt from apps.jwt_auth.services import login_by_username, login_by_mobile, verify_mobile_and_get_token -from django.contrib.auth import get_user_model -from rest_framework import status -CUser = get_user_model() - -# Create your views here. +from apps.jwt_auth.session import issue_tokens, validate_session_claims +from apps.workspace.models import Workspace -class VerifyMobileAndGetTokenView(APIView): - - class InputSerializer(serializers.Serializer): - mobile = serializers.IntegerField(required=True) - opt = serializers.IntegerField(required=True) +class RoleTypeSerializer(serializers.ModelSerializer): + class Meta: + model = RoleType + fields = ('role_name', 'role_name_en') - class OutputSerializer(serializers.ModelSerializer): - - class CUserRoleSerializer(serializers.ModelSerializer): - - class RoleTypeSerializer(serializers.ModelSerializer): - class Meta: - model = RoleTypeModel - fields = ('role_name', ) - - role_type = RoleTypeSerializer() - - class Meta: - model = CuserRoleModel - fields = ('name_family', 'title', 'role_type') - - cuser_role = CUserRoleSerializer() - +class RoleSerializer(serializers.ModelSerializer): + class WorkspaceSerializer(serializers.ModelSerializer): class Meta: - model = CUserRoleLoginOpt - fields = ('cuser_role', 'expire', 'uuid') + model = Workspace + fields = ('id', 'name') - def post(self, request): - serializer = self.InputSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + role_type = RoleTypeSerializer() + sys_ws = WorkspaceSerializer() - login_opts = verify_mobile_and_get_token(**serializer.validated_data) - - response_data = self.OutputSerializer(login_opts, many=True).data - - return Response(response_data) + class Meta: + model = CuserRole + fields = ('name_family', 'title', 'role_type', 'sys_ws') -class LoginByMobileView(APIView): +class LoginOptionSerializer(serializers.ModelSerializer): + cuser_role = RoleSerializer() + class Meta: + model = CUserRoleLoginOpt + fields = ('cuser_role', 'expire', 'uuid') + + +class PublicAuthView(APIView): + authentication_classes = () + permission_classes = (AllowAny,) + + def get_authenticate_header(self, request): + return 'Bearer realm="api"' + + +class VerifyMobileAndGetTokenView(PublicAuthView): class InputSerializer(serializers.Serializer): - mobile = serializers.IntegerField(required=True) + mobile = serializers.CharField(required=True) + opt = serializers.IntegerField(required=True, min_value=100000, max_value=999999) def post(self, request): serializer = self.InputSerializer(data=request.data) serializer.is_valid(raise_exception=True) - - login_by_mobile(**serializer.validated_data) - - output = { - "result": { - "mobile": serializer.validated_data.get('mobile'), - "message": "opt code send" - } - } - - return Response(output) + options = verify_mobile_and_get_token(**serializer.validated_data) + return Response(LoginOptionSerializer(options, many=True).data) -class LoginByUserView(APIView): +class LoginByMobileView(PublicAuthView): + class InputSerializer(serializers.Serializer): + mobile = serializers.CharField(required=True) + def post(self, request): + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + mobile = login_by_mobile(**serializer.validated_data) + return Response({'result': {'mobile': mobile, 'message': 'opt code send'}}) + + +class LoginByUserView(PublicAuthView): class InputSerializer(serializers.Serializer): username = serializers.CharField(required=True) - password = serializers.CharField(required=True) - - class OutputSerializer(serializers.ModelSerializer): - - class CUserRoleSerializer(serializers.ModelSerializer): - - class RoleTypeSerializer(serializers.ModelSerializer): - class Meta: - model = RoleTypeModel - fields = ('role_name', ) - - role_type = RoleTypeSerializer() - - class Meta: - model = CuserRoleModel - fields = ('name_family', 'title', 'role_type') - - cuser_role = CUserRoleSerializer() - - class Meta: - model = CUserRoleLoginOpt - fields = ('cuser_role', 'expire', 'uuid') + password = serializers.CharField(required=True, trim_whitespace=False, write_only=True) def post(self, request): serializer = self.InputSerializer(data=request.data) serializer.is_valid(raise_exception=True) - - username = serializer.validated_data.get('username') - password = serializer.validated_data.get('password') - - login_opts = login_by_username(username=username, password=password) - - response_data = self.OutputSerializer(login_opts, many=True).data - - return Response(response_data) + options = login_by_username(**serializer.validated_data) + return Response(LoginOptionSerializer(options, many=True).data) -class CustomTokenObtainPairView(APIView): - +class CustomTokenObtainPairView(PublicAuthView): class InputSerializer(serializers.Serializer): - uuid_opt = serializers.CharField(required=True) - - class OutputSerializer(serializers.Serializer): - access = serializers.CharField(required=True) - refresh = serializers.CharField(required=True) + uuid_opt = serializers.UUIDField(required=True) + @transaction.atomic def post(self, request): serializer = self.InputSerializer(data=request.data) serializer.is_valid(raise_exception=True) - - uuid_opt = serializer.validated_data.get('uuid_opt') - try: - user_login_opt = CUserRoleLoginOpt.objects.get(uuid=uuid_opt) - user = user_login_opt.cuser - cuser_role = user_login_opt.cuser_role + user_id = CUserRoleLoginOpt.objects.values_list('cuser_id', flat=True).get( + uuid=serializer.validated_data['uuid_opt'], expire__gt=timezone.now()) + CUser.objects.select_for_update().get(pk=user_id) + option = CUserRoleLoginOpt.objects.select_related( + 'cuser_role__cuser', 'cuser_role__role_type', 'cuser_role__sys_ws', + ).get(uuid=serializer.validated_data['uuid_opt'], expire__gt=timezone.now()) + except (CUserRoleLoginOpt.DoesNotExist, CUser.DoesNotExist): + raise InvalidToken('اعتبار درخواست ورود تمام شده است. دوباره وارد شوید.') + if option.cuser_id != option.cuser_role.cuser_id: + raise InvalidToken('درخواست ورود معتبر نیست.') + data = issue_tokens(option.cuser_role) + CUserRoleLoginOpt.objects.filter(cuser_id=option.cuser_id).delete() + return Response(data) - workspace = cuser_role.sys_ws - - if user_login_opt: - refresh = RefreshToken.for_user(user) - - #if 'user_id' in refresh: - # del refresh['user_id'] - #print(cuser_role.uuid) - - - '''try: - #customerObj = CustomerModel.objects.get(cuser_role__uuid=str(cuser_role.uuid)) - # workspace = customerObj.sys_ws - - workspace = cuser_role.sys_ws - - except: - operator = CustomerResponsibleModel.objects.get(cuser_role=cuser_role) - workspace = operator.sys_ws''' - - refresh['workspace_uuid'] = str(workspace.uuid) - - refresh['cuser_role_uuid'] = str(cuser_role.uuid) - - refresh['user_uuid'] = str(user.uuid) - - #print(str(refresh)) - - try: - jwt_db = JwtTokenModel(refresh=str(refresh), access=str(refresh.access_token), - cuser=user, cuser_role=cuser_role) - jwt_db.save() - - try: - CUserRoleLoginOpt.objects.filter(cuser=user).delete() - except Exception as exp: - pass - - except Exception as exp: - return Response({'error': str(exp)}) - - new_token_data = { - 'refresh': str(refresh), - 'access': str(refresh.access_token), - } - - out_data = self.OutputSerializer(data=new_token_data) - out_data.is_valid(raise_exception=True) - - return Response(out_data.data) - else: - return Response({"error": 'userpass is not True'}) - except CUserRoleLoginOpt.DoesNotExist: - return Response({"error": 'Token not valid'}) - except Exception as exp: - return Response({"error": 'user or pass is not true 2'}) - - -class CustomTokenRefreshView(APIView): - +class CustomTokenRefreshView(PublicAuthView): class InputSerializer(serializers.Serializer): refresh = serializers.CharField(required=True) - class OutputSerializer(serializers.Serializer): - access = serializers.CharField(required=True) + @transaction.atomic + def post(self, request): + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + refresh_value = serializer.validated_data['refresh'] + try: + refresh = RefreshToken(refresh_value) + session = JwtToken.objects.select_for_update().select_related( + 'cuser_role__cuser', 'cuser_role__role_type', 'cuser_role__sys_ws', + ).get(refresh=refresh_value, is_blocked=False) + except (TokenError, JwtToken.DoesNotExist): + raise InvalidToken('نشست شما منقضی یا باطل شده است. دوباره وارد شوید.') + validate_session_claims(session, refresh) + return Response(issue_tokens(session.cuser_role, session=session)) + + +class LogoutView(PublicAuthView): + class InputSerializer(serializers.Serializer): refresh = serializers.CharField(required=True) def post(self, request): - with transaction.atomic(): - try: - - serializer = self.InputSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - refresh_token = serializer.validated_data.get('refresh') - - print(refresh_token) - - # check: token is valid? - ref_token = RefreshToken(token=refresh_token) - - print("ref") - - # first token must exists in whitelist and not blocked - token_exist = JwtTokenModel.objects.get(refresh=str(refresh_token)) - - print("exsist") - - token_is_blocked = JwtTokenModel.objects.get(refresh=str(refresh_token)).is_blocked - - print("yess") - - if token_exist and token_is_blocked is False: - JwtTokenModel.objects.get(refresh=str(refresh_token)).delete() - else: - raise ValueError('refresh token is not in whitelist or blocked!') - - user_uuid = ref_token.payload.get('user_uuid') - user_id = ref_token.payload.get('user_id') - cuser_role_uuid = ref_token.payload.get('cuser_role_uuid') - - token_exp_timestamp = ref_token.payload.get('exp') - token_exp = timezone.make_aware(timezone.datetime.fromtimestamp(token_exp_timestamp), timezone.utc) - user = CUser.objects.get(id=user_id) - cuser_role = CuserRoleModel.objects.get(uuid=cuser_role_uuid) - - - print("OKH###") - - if ref_token: - new_token = RefreshToken.for_user(user) - - #if 'user_id' in new_token: - #del new_token['user_id'] - - new_token['user_uuid'] = str(user.uuid) - new_token['cuser_role_uuid'] = str(cuser_role.uuid) - - print("OKOKKO 555") - - #workspace = WorkspaceModel.objects.get(cuser_role_owner__uuid=str(cuser_role.uuid)) - - try: - customerObj = CustomerModel.objects.get(cuser_role__uuid=str(cuser_role.uuid)) - new_token['workspace_uuid'] = str(customerObj.sys_ws.uuid) - except: - operator = CustomerResponsibleModel.objects.get(cuser_role__uuid=str(cuser_role.uuid)) - new_token['workspace_uuid'] = str(operator.sys_ws.uuid) - - - new_access_token = new_token.access_token - - new_token_data = { - 'refresh': str(new_token), - 'access': str(new_access_token), - } - - print("OKOKKO") - - new_token_obj = JwtTokenModel(refresh=str(new_token), - access=str(new_token_data.get('access')), - expire=token_exp, - cuser=user, - cuser_role=cuser_role - ) - - print("OKOKKO 222") - new_token_obj.save() - out_data = self.OutputSerializer(data=new_token_data) - out_data.is_valid(raise_exception=True) - return Response(out_data.data) - - except Exception as e: - transaction.set_rollback(True) - return Response({"error": str(e)}) + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + value = serializer.validated_data['refresh'] + try: + token = RefreshToken(value) + except TokenError: + return Response(status=status.HTTP_204_NO_CONTENT) + # Possession of the signed refresh token permits logout even after access expires. + if type(token.get('session_id')) is int and type(token.get('user_id')) is int: + JwtToken.objects.filter(pk=token['session_id'], cuser_id=token['user_id']).update(is_blocked=True) + else: + JwtToken.objects.filter(refresh=value).update(is_blocked=True) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/lead/admin.py b/apps/lead/admin.py index ffdccbe..f3c19a5 100644 --- a/apps/lead/admin.py +++ b/apps/lead/admin.py @@ -1,40 +1,53 @@ +from utils.admin_base import CRMModelAdmin from django.contrib import admin from apps.lead.models import * # Register your models here. +class LeadCommentInline(admin.TabularInline): + model = LeadComment + extra = 0 + fields = ["desc","cuser_role","created_at"] + readonly_fields = fields + can_delete = False + show_change_link = True + + def has_add_permission(self, request, obj=None): + return False + + @admin.register(Lead) -class LeadAdmin(admin.ModelAdmin): +class LeadAdmin(CRMModelAdmin): pass @admin.register(LeadComment) -class LeadCommentAdmin(admin.ModelAdmin): +class LeadCommentAdmin(CRMModelAdmin): pass @admin.register(LeadStep) -class LeadStepAdmin(admin.ModelAdmin): +class LeadStepAdmin(CRMModelAdmin): pass @admin.register(LeadType) -class LeadTypeAdmin(admin.ModelAdmin): +class LeadTypeAdmin(CRMModelAdmin): pass @admin.register(LeadSource) -class LeadSourceAdmin(admin.ModelAdmin): +class LeadSourceAdmin(CRMModelAdmin): pass @admin.register(LeadReason) -class LeadReasonAdmin(admin.ModelAdmin): +class LeadReasonAdmin(CRMModelAdmin): pass @admin.register(LeadEvent) -class LeadEventAdmin(admin.ModelAdmin): +class LeadEventAdmin(CRMModelAdmin): pass diff --git a/apps/reminder/admin.py b/apps/reminder/admin.py index ce13ef7..bfc1523 100644 --- a/apps/reminder/admin.py +++ b/apps/reminder/admin.py @@ -1,3 +1,4 @@ +from utils.admin_base import CRMModelAdmin from django.contrib import admin from apps.reminder.models import * @@ -7,5 +8,5 @@ from apps.reminder.models import * @admin.register(Reminder) -class ReminderAdmin(admin.ModelAdmin): +class ReminderAdmin(CRMModelAdmin): pass diff --git a/apps/shop/admin.py b/apps/shop/admin.py index e211f1d..e5afc81 100644 --- a/apps/shop/admin.py +++ b/apps/shop/admin.py @@ -1,40 +1,133 @@ +from utils.admin_base import CRMModelAdmin +from utils.admin_excel import excel_response +from apps.shop.admin_import import ProductImportAdminMixin +from apps.shop.import_models import ProductImportBatch + from django.contrib import admin -from apps.shop.models import ( - ProductCategory as ProductCategoryModel, - Product as ProductModel, - Order as OrderModel, - OrderItem as OrderItemModel, - Cart as CartModel, - ProductType as ProductTypeModel -) -# Register your models here. +from django.urls import reverse +from django.utils.html import format_html + +from apps.shop.models import ProductCategory, Product, Order, OrderItem, Cart, ProductType +from apps.shop.ordering import category_ordering, product_ordering -@admin.register(ProductTypeModel) -class ProductTypeAdmin(admin.ModelAdmin): +def export_products(queryset): + headers = ('فضای کاری', 'دسته', 'محصول', 'کد محصول', 'قیمت', 'ترتیب نمایش دسته', 'ترتیب نمایش محصول', 'ترتیب اکسل دسته', 'ترتیب اکسل محصول', 'فقط نمایش', 'ویژگی‌ها') + categories = {category.pk: (category.name, category.parent_id) for category in + ProductCategory.objects.filter(sys_ws_id__in=queryset.values('sys_ws_id'))} + def path(category_id): + names, visited = [], set() + while category_id in categories and category_id not in visited: + visited.add(category_id) + name, category_id = categories[category_id] + names.append(name) + return '/'.join(reversed(names)) + rows = ((item.sys_ws.name, path(item.category_id), item.name, item.acc_code, item.price, + item.category.order_cat, item.order_prd, item.category.excel_order, item.excel_order, + item.view_only, item.attr) for item in queryset.select_related('sys_ws', 'category').iterator()) + return excel_response(headers, rows, 'products.xlsx') + + +class ProductInline(admin.TabularInline): + model = Product + fields = ('name', 'price', 'order_prd', 'excel_order') + extra = 0 + show_change_link = True + readonly_fields = ('name', 'price') + can_delete = False + + def has_add_permission(self, request, obj=None): + return False + + +@admin.register(ProductType) +class ProductTypeAdmin(CRMModelAdmin): pass -@admin.register(ProductCategoryModel) -class ProductCategoryAdmin(admin.ModelAdmin): - pass +@admin.register(ProductCategory) +class ProductCategoryAdmin(CRMModelAdmin): + list_display = ('name', 'sys_ws', 'parent', 'order_cat', 'excel_order') + list_editable = ('order_cat', 'excel_order') + list_filter = ('sys_ws',) + search_fields = ('name',) + inlines = (ProductInline,) + actions = ('export_display_order', 'export_excel_order') + + @admin.action(description='خروجی اکسل دسته‌ها با ترتیب نمایش فعلی') + def export_display_order(self, request, queryset): + return self.export_categories(queryset) + + @admin.action(description='خروجی اکسل دسته‌ها با ترتیب مستقل اکسل') + def export_excel_order(self, request, queryset): + return self.export_categories(queryset.order_by(*category_ordering(excel=True))) + + def export_categories(self, queryset): + return excel_response(('فضای کاری', 'دسته', 'ترتیب نمایش', 'ترتیب اکسل'), + queryset.values_list('sys_ws__name', 'name', 'order_cat', 'excel_order'), 'categories.xlsx') -@admin.register(ProductModel) -class ProductAdmin(admin.ModelAdmin): - search_fields = ("name", "category__name", "acc_code") +@admin.register(Product) +class ProductAdmin(ProductImportAdminMixin, CRMModelAdmin): + list_display = ('name', 'category', 'sys_ws', 'price', 'order_prd', 'excel_order', 'view_only') + list_editable = ('order_prd', 'excel_order') + list_filter = ('sys_ws', 'category', 'view_only') + search_fields = ('name', 'category__name', 'acc_code') + list_select_related = ('category', 'sys_ws') + actions = ('export_display_order', 'export_excel_order') + + @admin.action(description='خروجی اکسل محصولات با ترتیب نمایش فعلی') + def export_display_order(self, request, queryset): + return export_products(queryset) + + @admin.action(description='خروجی اکسل محصولات با ترتیب مستقل اکسل') + def export_excel_order(self, request, queryset): + return export_products(queryset.order_by(*product_ordering(excel=True))) -@admin.register(OrderModel) -class OrderAdmin(admin.ModelAdmin): - pass +@admin.register(Order) +class OrderAdmin(CRMModelAdmin): + list_display = ('id', 'customer', 'sys_ws', 'total_price', 'factor_type', 'factor_status', 'view_at', 'created_at') -@admin.register(OrderItemModel) -class OrderItemAdmin(admin.ModelAdmin): - pass +@admin.register(OrderItem) +class OrderItemAdmin(CRMModelAdmin): + list_display = ('id', 'order', 'product', 'product_name', 'price', 'amount') + search_fields = ('product_name', 'product__name', 'order__customer__name_family') -@admin.register(CartModel) -class CartAdmin(admin.ModelAdmin): - pass +@admin.register(Cart) +class CartAdmin(CRMModelAdmin): + list_display = ('id', 'cuser_role', 'product', 'quantity', 'sys_ws', 'updated_at') + + +@admin.register(ProductImportBatch) +class ProductImportBatchAdmin(CRMModelAdmin): + list_display = ("id", "filename", "sys_ws", "created_by", "summary", "created_at", "confirmed_at") + search_fields = ("filename", "created_by__username") + exclude = ("payload", "fingerprint", "result") + sensitive_fields = ("payload", "fingerprint", "result") + actions = None + + def get_readonly_fields(self, request, obj=None): + fields = tuple(field.name for field in self.model._meta.fields if field.name not in self.exclude) + return (*fields, 'summary', 'report_link') if obj and obj.created_by_id == request.user.pk else (*fields, 'summary') + + @admin.display(description='خلاصه نتیجه') + def summary(self, obj): + result = obj.result + status = 'ثبت نهایی' if obj.confirmed_at else 'پیش‌نمایش' + return f"{status} | ایجاد: {result.get('created', 0)} | به‌روزرسانی: {result.get('updated', 0)} | خطا: {result.get('errors', 0)}" + + @admin.display(description='گزارش ردیف‌ها') + def report_link(self, obj): + return format_html('دریافت اکسل گزارش', reverse('admin:shop_product_import_report', args=[obj.pk])) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False diff --git a/apps/shop/admin_import.py b/apps/shop/admin_import.py new file mode 100644 index 0000000..f2f7857 --- /dev/null +++ b/apps/shop/admin_import.py @@ -0,0 +1,113 @@ +import json + +from django import forms +from django.core.exceptions import PermissionDenied, ValidationError +from django.shortcuts import get_object_or_404 +from django.template.response import TemplateResponse +from django.urls import path + +from apps.shop.import_models import ProductImportBatch +from apps.shop.product_import import HEADERS, parse_workbook +from apps.shop.import_batches import preview_batch, confirm_batch +from apps.workspace.models import Workspace +from utils.admin_excel import excel_response + + +class ProductImportForm(forms.Form): + workspace = forms.ModelChoiceField(label='فضای کاری مقصد', queryset=Workspace.objects.all()) + file = forms.FileField(label='فایل xlsx') + sheet = forms.CharField(label='نام برگه (خالی = اولین برگه)', required=False) + header_row = forms.IntegerField(label='شماره ردیف سرستون', min_value=1, max_value=100, initial=1) + mode = forms.ChoiceField(label='نوع ورود', choices=(('create', 'فقط ایجاد'), ('upsert', 'ایجاد و به‌روزرسانی'), ('update', 'فقط به‌روزرسانی'))) + create_categories = forms.BooleanField(label='ساخت دسته‌های ناموجود', required=False, initial=True) + errors = forms.ChoiceField(label='مدیریت خطا', choices=(('atomic', 'در صورت هر خطا، هیچ ردیفی ثبت نشود'), ('skip', 'فقط ردیف‌های معتبر ثبت شوند'))) + ordering = forms.ChoiceField(label='ترتیب نمایش محصولات', choices=(('preserve', 'مقادیر فایل؛ خالی = حفظ مقدار فعلی'), ('rows', 'ترتیب ردیف فایل برای مقادیر خالی'), ('append', 'محصول جدید در انتهای دسته'))) + mapping = forms.CharField(label='نگاشت اختیاری ستون‌ها (JSON)', required=False, widget=forms.Textarea(attrs={'rows': 3}), + help_text='مثال: {"name": "عنوان کالا", "category": "گروه", "acc_code": "شناسه"}') + + def clean_mapping(self): + try: + value = json.loads(self.cleaned_data['mapping'] or '{}') + if not isinstance(value, dict) or not all(isinstance(key, str) and isinstance(item, str) for key, item in value.items()): + raise ValueError + return value + except ValueError: + raise forms.ValidationError('یک شیء JSON با کلید و مقدار متنی وارد کنید.') + + +class ProductImportAdminMixin: + change_list_template = 'admin/shop/product/change_list.html' + + def import_allowed(self, request): + return (self.has_add_permission(request) and self.has_change_permission(request) + and request.user.has_perm('shop.view_productcategory')) + + def get_urls(self): + return [path('import-excel/', self.admin_site.admin_view(self.import_view), name='shop_product_import'), + path('import-template/', self.admin_site.admin_view(self.import_template), name='shop_product_import_template'), + path('import-report//', self.admin_site.admin_view(self.import_report), name='shop_product_import_report')] + super().get_urls() + + def changelist_view(self, request, extra_context=None): + return super().changelist_view(request, dict(extra_context or {}, can_import=self.import_allowed(request))) + + def import_template(self, request): + if not self.import_allowed(request): + raise PermissionDenied + return excel_response(HEADERS.keys(), [('محصول نمونه', 'دسته اصلی/زیردسته', 'CODE-001', 1000, 1, 1, 1, 1, False, '{}', 'عدد')], 'product-import-template.xlsx') + + def import_report(self, request, batch_id): + if not self.import_allowed(request): + raise PermissionDenied + batch = get_object_or_404(ProductImportBatch, pk=batch_id, created_by=request.user) + rows = ((row['row'], *(row['values'].get(key) for key in HEADERS), row['status'], row['errors']) + for row in batch.result.get('rows', [])) + return excel_response(('ردیف', *HEADERS, 'وضعیت', 'خطاها'), rows, f'import-report-{batch.pk}.xlsx') + + def import_view(self, request): + if not self.import_allowed(request): + raise PermissionDenied + form = ProductImportForm(request.POST or None, request.FILES or None) + batch, problem = None, None + if request.method == 'POST': + if request.POST.get('confirm'): + try: + batch = self.confirm_import(request) + except ValidationError as error: + problem = '؛ '.join(error.messages) + elif form.is_valid(): + try: + batch = self.preview_import(request, form.cleaned_data) + except ValidationError as error: + form.add_error(None, error) + context = dict(self.admin_site.each_context(request), opts=self.model._meta, title='ورود محصولات از اکسل', + form=form, batch=batch, problem=problem, + result=batch.result if batch else None, can_confirm=batch and not batch.confirmed_at + and (batch.payload['options']['errors'] == 'skip' or not batch.result['errors'])) + return TemplateResponse(request, 'admin/shop/product/import.html', context) + + def preview_import(self, request, data): + if data['create_categories'] and not request.user.has_perm('shop.add_productcategory'): + raise PermissionDenied + # Category-order permission is checked against the parsed file before preview writes. + rows = parse_workbook(data['file'], data['sheet'], data['header_row'], data['mapping']) + if any(row['values'].get(key) for row in rows for key in ('order_cat', 'category_excel_order')): + if not request.user.has_perm('shop.change_productcategory'): + raise PermissionDenied + data['file'].seek(0) + options = {key: data[key] for key in ('mode', 'create_categories', 'errors', 'ordering')} + return preview_batch(request.user, data['workspace'], data['file'], options, + data['sheet'], data['header_row'], data['mapping']) + + def confirm_import(self, request): + try: + batch_id = int(request.POST['batch_id']) + except (KeyError, ValueError, TypeError): + raise ValidationError('شناسه پیش‌نمایش معتبر نیست.') + batch = get_object_or_404(ProductImportBatch, pk=batch_id, created_by=request.user) + options = batch.payload['options'] + if options['create_categories'] and not request.user.has_perm('shop.add_productcategory'): + raise PermissionDenied + if any(row['values'].get(key) for row in batch.payload['rows'] for key in ('order_cat', 'category_excel_order')): + if not request.user.has_perm('shop.change_productcategory'): + raise PermissionDenied + return confirm_batch(batch_id, request.user) diff --git a/apps/shop/import_batches.py b/apps/shop/import_batches.py new file mode 100644 index 0000000..04b5f9a --- /dev/null +++ b/apps/shop/import_batches.py @@ -0,0 +1,59 @@ +"""Shared import lifecycle; callers enforce their own admin permission boundary.""" +import json +from datetime import timedelta +from django.contrib.admin.models import LogEntry, ADDITION +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.utils import timezone + +from apps.shop.import_models import ProductImportBatch +from apps.shop.models import Product +from apps.shop.product_import import parse_workbook, import_products, catalog_fingerprint, lock_catalog, HEADERS +from utils.admin_excel import excel_response + + +@transaction.atomic +def preview_batch(owner, workspace, upload, options, sheet='', header_row=1, mapping=None): + rows = parse_workbook(upload, sheet, header_row, mapping) + lock_catalog(workspace) + fingerprint = catalog_fingerprint(workspace) + result = import_products(workspace, rows, options, dry_run=True) + return ProductImportBatch.objects.create(sys_ws=workspace, created_by=owner, + expires_at=timezone.now()+timedelta(minutes=30), filename=upload.name[:255], + fingerprint=fingerprint, payload={'rows': rows, 'options': options}, result=result) + + +@transaction.atomic +def confirm_batch(batch_id, owner, workspace=None): + queryset = ProductImportBatch.objects.select_for_update().filter(created_by=owner) + if workspace is not None: + queryset = queryset.filter(sys_ws=workspace) + batch = get_object_or_404(queryset, pk=batch_id) + if batch.confirmed_at: + return batch + if batch.expires_at <= timezone.now(): + raise ValidationError('پیش‌نمایش منقضی شده؛ فایل را دوباره اعتبارسنجی کنید.') + lock_catalog(batch.sys_ws) + if catalog_fingerprint(batch.sys_ws) != batch.fingerprint: + raise ValidationError('محصولات یا دسته‌ها پس از پیش‌نمایش تغییر کرده‌اند؛ پیش‌نمایش تازه بسازید.') + result = import_products(batch.sys_ws, batch.payload['rows'], batch.payload['options']) + batch.result = result + if result['committed']: + batch.confirmed_at = timezone.now() + LogEntry.objects.log_action(user_id=owner.pk, content_type_id=ContentType.objects.get_for_model(Product).pk, + object_id=None, object_repr=f'Excel import #{batch.pk}', action_flag=ADDITION, + change_message=json.dumps({'import': batch.pk, 'created': result['created'], 'updated': result['updated'], 'errors': result['errors']})) + batch.save(update_fields=('result', 'confirmed_at')) + return batch + + +def batch_report(batch): + rows = ((row['row'], *(row['values'].get(key) for key in HEADERS), row['status'], row['errors']) + for row in batch.result.get('rows', [])) + return excel_response(('ردیف', *HEADERS, 'وضعیت', 'خطاها'), rows, f'import-report-{batch.pk}.xlsx') + + +def import_template(): + return excel_response(HEADERS.keys(), [('محصول نمونه', 'دسته اصلی/زیردسته', 'CODE-001', 1000, 1, 1, 1, 1, False, '{}', 'عدد')], 'product-import-template.xlsx') diff --git a/apps/shop/import_models.py b/apps/shop/import_models.py new file mode 100644 index 0000000..637a103 --- /dev/null +++ b/apps/shop/import_models.py @@ -0,0 +1,22 @@ +from django.conf import settings +from django.db import models + + +class ProductImportBatch(models.Model): + sys_ws = models.ForeignKey('workspace.Workspace', on_delete=models.CASCADE) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField() + confirmed_at = models.DateTimeField(null=True, blank=True) + filename = models.CharField(max_length=255) + fingerprint = models.CharField(max_length=64) + payload = models.JSONField() + result = models.JSONField(default=dict) + + class Meta: + ordering = ('-pk',) + verbose_name = 'ورود اکسل محصولات' + verbose_name_plural = 'سوابق ورود اکسل محصولات' + + def __str__(self): + return f'{self.pk}: {self.filename}' diff --git a/apps/shop/management_serializers.py b/apps/shop/management_serializers.py new file mode 100644 index 0000000..486278d --- /dev/null +++ b/apps/shop/management_serializers.py @@ -0,0 +1,89 @@ +from rest_framework import serializers + +from apps.shop.models import Product, ProductCategory + + +class CategoryManagementSerializer(serializers.ModelSerializer): + order_cat = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647) + excel_order = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647) + product_count = serializers.IntegerField(read_only=True) + child_count = serializers.IntegerField(read_only=True) + parent = serializers.PrimaryKeyRelatedField(queryset=ProductCategory.objects.all(), allow_null=True, required=False) + + class Meta: + model = ProductCategory + fields = ('id', 'name', 'parent', 'image', 'order_cat', 'excel_order', 'product_count', 'child_count') + + def validate(self, attrs): + workspace = self.context['workspace'] + parent = attrs.get('parent', self.instance.parent if self.instance else None) + if parent and parent.sys_ws_id != workspace.pk: + raise serializers.ValidationError({'parent': 'دسته والد متعلق به فضای کاری دیگری است.'}) + visited = set() + cursor = parent + while cursor: + if cursor.pk in visited or self.instance and cursor.pk == self.instance.pk: + raise serializers.ValidationError({'parent': 'انتخاب این والد باعث چرخه در دسته‌ها می‌شود.'}) + visited.add(cursor.pk) + cursor = cursor.parent + name = attrs.get('name', self.instance.name if self.instance else '') + matches = ProductCategory.objects.filter(sys_ws=workspace, name=name, parent=parent) + if self.instance: + matches = matches.exclude(pk=self.instance.pk) + if matches.exists(): + raise serializers.ValidationError({'name': 'این نام در همین دسته والد قبلاً ثبت شده است.'}) + return attrs + + +class ProductManagementSerializer(serializers.ModelSerializer): + order_prd = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647) + excel_order = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647) + category_name = serializers.CharField(source='category.name', read_only=True) + price = serializers.IntegerField(min_value=0, max_value=9223372036854775807) + category = serializers.PrimaryKeyRelatedField(queryset=ProductCategory.objects.all()) + acc_code = serializers.CharField(required=False, allow_blank=True, allow_null=True) + + class Meta: + model = Product + fields = ('id', 'name', 'price', 'category', 'category_name', 'acc_code', 'attr', 'view_only', 'order_prd', 'excel_order', 'created_at', 'updated_at') + read_only_fields = ('id', 'created_at', 'updated_at') + + def validate_category(self, category): + if category.sys_ws_id != self.context['workspace'].pk: + raise serializers.ValidationError('دسته متعلق به فضای کاری دیگری است.') + return category + + def validate_acc_code(self, code): + if code: + matches = Product.objects.filter(sys_ws=self.context['workspace'], acc_code=code) + if self.instance: + matches = matches.exclude(pk=self.instance.pk) + if matches.exists(): + raise serializers.ValidationError('کد محصول در این فضای کاری تکراری است.') + return code + + def validate_attr(self, value): + if value is not None and not isinstance(value, dict): + raise serializers.ValidationError('ویژگی‌ها باید یک شیء JSON باشد.') + return value + + def to_representation(self, instance): + data = super().to_representation(instance) + data['price'] = str(instance.price) + return data + + +class ImportOptionsSerializer(serializers.Serializer): + file = serializers.FileField() + sheet = serializers.CharField(required=False, allow_blank=True, default='') + header_row = serializers.IntegerField(min_value=1, max_value=100, default=1) + mode = serializers.ChoiceField(choices=('create', 'upsert', 'update'), default='create') + create_categories = serializers.BooleanField(default=True) + errors = serializers.ChoiceField(choices=('atomic', 'skip'), default='atomic') + ordering = serializers.ChoiceField(choices=('preserve', 'rows', 'append'), default='preserve') + mapping = serializers.JSONField(default=dict) + + def validate_mapping(self, value): + if not isinstance(value, dict) or not all(isinstance(key, str) and isinstance(item, str) for key, item in value.items()): + raise serializers.ValidationError('نگاشت ستون‌ها باید شیء JSON با مقدارهای متنی باشد.') + return value diff --git a/apps/shop/migrations/0021_product_display_excel_ordering.py b/apps/shop/migrations/0021_product_display_excel_ordering.py new file mode 100644 index 0000000..7a39ac9 --- /dev/null +++ b/apps/shop/migrations/0021_product_display_excel_ordering.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.1 on 2026-10-08 08:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0020_product_view_only'), + ] + + operations = [ + migrations.AlterModelOptions( + name='product', + options={'ordering': (models.OrderBy(models.F('category__order_cat'), nulls_last=True), 'category__name', 'category__pk', models.OrderBy(models.F('order_prd'), nulls_last=True), 'name', 'pk')}, + ), + migrations.AlterModelOptions( + name='productcategory', + options={'ordering': (models.OrderBy(models.F('order_cat'), nulls_last=True), 'name', 'pk')}, + ), + migrations.AddField( + model_name='product', + name='excel_order', + field=models.PositiveIntegerField(blank=True, default=None, help_text='عدد کمتر زودتر نمایش داده می\u200cشود؛ خالی یعنی استفاده از ترتیب نمایش.', null=True, verbose_name='ترتیب محصول در اکسل'), + ), + migrations.AddField( + model_name='productcategory', + name='excel_order', + field=models.PositiveIntegerField(blank=True, default=None, help_text='عدد کمتر زودتر نمایش داده می\u200cشود؛ خالی یعنی استفاده از ترتیب نمایش.', null=True, verbose_name='ترتیب دسته در اکسل'), + ), + migrations.AlterField( + model_name='product', + name='order_prd', + field=models.PositiveIntegerField(blank=True, default=None, null=True, verbose_name='ترتیب نمایش محصول'), + ), + migrations.AlterField( + model_name='productcategory', + name='order_cat', + field=models.PositiveIntegerField(blank=True, default=None, null=True, verbose_name='ترتیب نمایش دسته'), + ), + ] diff --git a/apps/shop/migrations/0022_productimportbatch.py b/apps/shop/migrations/0022_productimportbatch.py new file mode 100644 index 0000000..82e068c --- /dev/null +++ b/apps/shop/migrations/0022_productimportbatch.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.1 on 2026-10-08 09:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('workspace', '0016_remove_workspace_cuser_owner'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('shop', '0021_product_display_excel_ordering'), + ] + + operations = [ + migrations.CreateModel( + name='ProductImportBatch', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField()), + ('confirmed_at', models.DateTimeField(blank=True, null=True)), + ('filename', models.CharField(max_length=255)), + ('fingerprint', models.CharField(max_length=64)), + ('payload', models.JSONField()), + ('result', models.JSONField(default=dict)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ('sys_ws', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='workspace.workspace')), + ], + options={ + 'verbose_name': 'ورود اکسل محصولات', + 'verbose_name_plural': 'سوابق ورود اکسل محصولات', + 'ordering': ('-pk',), + }, + ), + ] diff --git a/apps/shop/models.py b/apps/shop/models.py index b4b0733..8b5398d 100644 --- a/apps/shop/models.py +++ b/apps/shop/models.py @@ -1,8 +1,10 @@ from django.db import models +from apps.shop.import_models import ProductImportBatch from utils.base_model import BaseModel from apps.workspace.models import Workspace as WorkspaceModel from apps.cuser.models import CuserRole as CuserRoleModel from apps.customer.models import ResponsibleGroup as ResponsibleGroupModel, Customer as CustomerModel +from apps.shop.ordering import category_ordering, product_ordering # Create your models here. @@ -18,9 +20,14 @@ class ProductCategory(BaseModel): name = models.CharField(max_length=60, null=False, blank=False) parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True) image = models.ImageField(null=True, blank=True, default=None, upload_to='product-cat/') - order_cat = models.PositiveIntegerField(null=True, blank=True, default=None) + order_cat = models.PositiveIntegerField('ترتیب نمایش دسته', null=True, blank=True, default=None) + excel_order = models.PositiveIntegerField('ترتیب دسته در اکسل', null=True, blank=True, default=None, + help_text='عدد کمتر زودتر نمایش داده می‌شود؛ خالی یعنی استفاده از ترتیب نمایش.') sys_ws = models.ForeignKey(WorkspaceModel, on_delete=models.CASCADE, null=False, blank=False) + class Meta: + ordering = category_ordering() + def __str__(self): return f'{self.name}' @@ -30,12 +37,17 @@ class Product(BaseModel): price = models.PositiveBigIntegerField(null=False, blank=False) category = models.ForeignKey(ProductCategory, on_delete=models.CASCADE, null=False, blank=False) acc_code = models.CharField(null=True, blank=True) - order_prd = models.PositiveIntegerField(null=True, blank=True, default=None) + order_prd = models.PositiveIntegerField('ترتیب نمایش محصول', null=True, blank=True, default=None) + excel_order = models.PositiveIntegerField('ترتیب محصول در اکسل', null=True, blank=True, default=None, + help_text='عدد کمتر زودتر نمایش داده می‌شود؛ خالی یعنی استفاده از ترتیب نمایش.') attr = models.JSONField(null=True, blank=True) view_only = models.BooleanField(null=True, blank=True, default=False) sys_ws = models.ForeignKey(WorkspaceModel, on_delete=models.CASCADE, null=False, blank=False) + class Meta: + ordering = product_ordering() + def __str__(self): return f'{self.name}' diff --git a/apps/shop/ordering.py b/apps/shop/ordering.py new file mode 100644 index 0000000..c7c8b1b --- /dev/null +++ b/apps/shop/ordering.py @@ -0,0 +1,18 @@ +from django.db.models import F +from django.db.models.functions import Coalesce + + +def category_ordering(prefix='', excel=False): + field = F(prefix + 'order_cat') + if excel: + field = Coalesce(prefix + 'excel_order', prefix + 'order_cat') + return (field.asc(nulls_last=True), prefix + 'name', prefix + 'pk') + + +def product_ordering(prefix='', excel=False): + field = F(prefix + 'order_prd') + if excel: + field = Coalesce(prefix + 'excel_order', prefix + 'order_prd') + return category_ordering(prefix + 'category__', excel) + ( + field.asc(nulls_last=True), prefix + 'name', prefix + 'pk', + ) diff --git a/apps/shop/product_import.py b/apps/shop/product_import.py new file mode 100644 index 0000000..28e0288 --- /dev/null +++ b/apps/shop/product_import.py @@ -0,0 +1,274 @@ +"""Bounded Excel parsing and transactional product import, shared by admin views.""" +import hashlib +import json +from decimal import Decimal, InvalidOperation +from io import BytesIO +from zipfile import ZipFile + +from django.core.exceptions import ValidationError +from django.db import DataError, IntegrityError, transaction +from django.db.models import Max +from openpyxl import load_workbook + +from apps.shop.models import Product, ProductCategory +from apps.workspace.models import Workspace + +MAX_ROWS = 5000 +MAX_FILE_BYTES = 2 * 1024 * 1024 +HEADERS = { + 'name': ('name', 'محصول', 'نام محصول'), + 'category': ('category', 'cate_name', 'دسته', 'مسیر دسته'), + 'acc_code': ('acc_code', 'کد محصول', 'کد'), + 'price': ('price', 'قیمت'), + 'order_prd': ('order_prd', 'size', 'ترتیب نمایش محصول'), + 'excel_order': ('excel_order', 'ترتیب اکسل محصول'), + 'order_cat': ('order_cat', 'ترتیب نمایش دسته'), + 'category_excel_order': ('category_excel_order', 'ترتیب اکسل دسته'), + 'view_only': ('view_only', 'فقط نمایش'), + 'attr': ('attr', 'ویژگی‌ها'), + 'unit': ('unit', 'vahed', 'واحد'), +} +DIGITS = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789') + + +def text_value(value): + if value is None: + return '' + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value).strip() + + +def integer_value(value, field, maximum=2147483647): + try: + number = Decimal(text_value(value).translate(DIGITS).replace(',', '').replace('٬', '')) + if not number.is_finite() or number != number.to_integral_value() or not 0 <= number <= maximum: + raise InvalidOperation + return int(number) + except (InvalidOperation, ValueError): + raise ValidationError({field: 'عدد صحیح نامنفی در محدوده مجاز وارد کنید.'}) + + +def boolean_value(value): + normalized = text_value(value).translate(DIGITS).lower() + if normalized in ('1', 'true', 'yes', 'بله'): + return True + if normalized in ('0', 'false', 'no', 'خیر'): + return False + raise ValidationError({'view_only': 'مقدار بله/خیر یا true/false وارد کنید.'}) + + +def parse_workbook(upload, sheet_name='', header_row=1, mapping=None): + """Never evaluate formula cells; cap compressed and uncompressed workbook sizes.""" + if not upload.name.lower().endswith('.xlsx'): + raise ValidationError('فقط فایل xlsx پذیرفته می‌شود؛ فایل xls را به xlsx تبدیل کنید.') + content = upload.read(MAX_FILE_BYTES + 1) + if len(content) > MAX_FILE_BYTES: + raise ValidationError('حداکثر حجم فایل ۲ مگابایت است.') + try: + with ZipFile(BytesIO(content)) as archive: + if sum(item.file_size for item in archive.infolist()) > 25 * 1024 * 1024: + raise ValidationError('حجم بازشده فایل بیش از حد مجاز است.') + book = load_workbook(BytesIO(content), read_only=True, data_only=False) + except ValidationError: + raise + except Exception: + # Workbook parsers raise several XML/ZIP-specific exception types. + raise ValidationError('فایل اکسل خراب است یا ساختار معتبر ندارد.') + try: + if sheet_name and sheet_name not in book.sheetnames: + raise ValidationError('برگه انتخاب‌شده در فایل وجود ندارد.') + sheet = book[sheet_name] if sheet_name else book.worksheets[0] + if sheet.max_row and sheet.max_row > header_row + MAX_ROWS: + raise ValidationError('حداکثر ۵۰۰۰ ردیف پس از سرستون مجاز است؛ ردیف‌های خالی انتهای فایل را حذف کنید.') + if sheet.max_column and sheet.max_column > 100: + raise ValidationError('حداکثر ۱۰۰ ستون مجاز است.') + iterator = sheet.iter_rows(min_row=header_row, max_row=header_row + MAX_ROWS + 1, max_col=100) + headers = [text_value(cell.value) for cell in next(iterator, ())] + indexes = resolve_headers(headers, mapping or {}) + rows = [] + for number, cells in enumerate(iterator, header_row + 1): + if not any(cell.value is not None for cell in cells): + continue + if number > header_row + MAX_ROWS: + raise ValidationError('حداکثر ۵۰۰۰ ردیف پس از سرستون مجاز است.') + values = {key: text_value(cells[index].value) for key, index in indexes.items()} + formulas = [key for key, index in indexes.items() if cells[index].data_type == 'f'] + rows.append({'row': number, 'values': values, 'formulas': formulas}) + if not rows: + raise ValidationError('فایل ردیف محصول ندارد.') + return rows + except ValidationError: + raise + except Exception: + raise ValidationError('محتوای برگه اکسل خراب است یا خوانده نمی‌شود.') + finally: + book.close() + + +def resolve_headers(headers, mapping): + if set(mapping) - set(HEADERS): + raise ValidationError('کلید نگاشت ستون ناشناخته است.') + indexes = {} + for key, aliases in HEADERS.items(): + names = (mapping[key],) if key in mapping else aliases + matches = [index for index, name in enumerate(headers) if name and name in names] + if len(matches) > 1: + raise ValidationError(f'ستون {key} تکراری یا مبهم است.') + if matches: + indexes[key] = matches[0] + if len(set(indexes.values())) != len(indexes): + raise ValidationError('یک ستون به چند فیلد نگاشت شده است.') + if not {'name', 'category', 'acc_code'}.issubset(indexes): + raise ValidationError('ستون‌های نام محصول، دسته و کد محصول الزامی هستند.') + return indexes + + +def catalog_fingerprint(workspace): + """Detect intervening writes between preview and confirmation.""" + digest = hashlib.sha256() + for model in (ProductCategory, Product): + for item in model.objects.filter(sys_ws=workspace).order_by('pk').values().iterator(): + digest.update(json.dumps(item, sort_keys=True, default=str, ensure_ascii=False).encode()) + return digest.hexdigest() + + +def lock_catalog(workspace): + """Lock existing rows in stable order as well as the workspace during confirmation.""" + Workspace.objects.select_for_update().get(pk=workspace.pk) + for model in (ProductCategory, Product): + list(model.objects.select_for_update().filter(sys_ws=workspace).order_by('pk').values_list('pk', flat=True)) + + +def category_for_path(workspace, path, create): + names = [name.strip() for name in path.split('/')] + if not names or len(names) > 10 or any(not name or len(name) > 60 for name in names): + raise ValidationError({'category': 'مسیر دسته با /، حداکثر ۱۰ سطح و نام‌های ۱ تا ۶۰ کاراکتری لازم است.'}) + parent = None + for name in names: + matches = ProductCategory.objects.filter(sys_ws=workspace, name=name, parent=parent) + if matches.count() > 1: + raise ValidationError({'category': 'دسته تکراری و مبهم است؛ ابتدا دسته‌های موجود را اصلاح کنید.'}) + category = matches.first() + if category is None: + if not create: + raise ValidationError({'category': f'دسته {name} وجود ندارد و ساخت دسته غیرفعال است.'}) + category = ProductCategory(sys_ws=workspace, name=name, parent=parent) + category.full_clean() + category.save() + parent = category + return parent + + +def product_values(values): + fields = {'name': values.get('name', '').strip()} + if not fields['name']: + raise ValidationError({'name': 'نام محصول الزامی است.'}) + for key in ('price', 'order_prd', 'excel_order'): + if values.get(key): + maximum = 9223372036854775807 if key == 'price' else 2147483647 + fields[key] = integer_value(values[key], key, maximum) + if values.get('view_only'): + fields['view_only'] = boolean_value(values['view_only']) + if values.get('attr'): + try: + fields['attr'] = json.loads(values['attr']) + if not isinstance(fields['attr'], dict): + raise ValueError + except (ValueError, TypeError): + raise ValidationError({'attr': 'ویژگی‌ها باید یک شیء JSON معتبر باشد.'}) + return fields + + +def apply_product_row(workspace, row, options): + values = row['values'] + if row.get('formulas'): + raise ValidationError({key: 'فرمول مجاز نیست؛ مقدار ثابت جایگزین کنید.' for key in row['formulas']}) + code = values.get('acc_code', '').strip() + if not code: + raise ValidationError({'acc_code': 'کد ثابت و غیرخالی محصول الزامی است.'}) + matches = Product.objects.filter(sys_ws=workspace, acc_code=code) + if matches.count() > 1: + raise ValidationError({'acc_code': 'کد در دیتابیس تکراری است؛ تطبیق یکتا امکان‌پذیر نیست.'}) + product = matches.first() + mode = options['mode'] + if product is not None and mode == 'create': + raise ValidationError({'acc_code': 'محصول موجود است؛ حالت ایجاد اجازه به‌روزرسانی ندارد.'}) + if product is None and mode == 'update': + raise ValidationError({'acc_code': 'محصول برای به‌روزرسانی پیدا نشد.'}) + created = product is None + fields = product_values(values) + category = category_for_path(workspace, values.get('category', ''), options['create_categories']) + changed_order = False + for source, target in (('order_cat', 'order_cat'), ('category_excel_order', 'excel_order')): + if values.get(source): + setattr(category, target, integer_value(values[source], source)) + changed_order = True + if changed_order: + category.full_clean() + category.save() + product = product or Product(sys_ws=workspace, acc_code=code, price=0) + product.category = category + for key, value in fields.items(): + setattr(product, key, value) + if values.get('unit'): + product.attr = dict(product.attr or {}, acc_vahed=values['unit']) + set_product_order(product, values, options['ordering']) + product.full_clean() + product.save() + return 'created' if created else 'updated' + + +def set_product_order(product, values, ordering): + if ordering == 'append' and product.pk is None and not values.get('order_prd'): + maximum = Product.objects.filter(category=product.category).aggregate(value=Max('order_prd'))['value'] + product.order_prd = (maximum or 0) + 1 + + +def import_products(workspace, rows, options, dry_run=False): + """Each rejected row rolls back its categories too; atomic mode rolls back all rows.""" + if options.get('mode') not in ('create', 'update', 'upsert') or options.get('errors') not in ('atomic', 'skip') or options.get('ordering') not in ('preserve', 'rows', 'append') or not isinstance(options.get('create_categories'), bool): + raise ValidationError('تنظیمات ورود معتبر نیست.') + results, seen, category_orders = [], set(), {} + with transaction.atomic(): + Workspace.objects.select_for_update().get(pk=workspace.pk) + for position, row in enumerate(rows, 1): + previous_orders = dict(category_orders) + values = dict(row['values']) + if options['ordering'] == 'rows' and not values.get('order_prd'): + values['order_prd'] = str(position) + candidate = dict(row, values=values) + try: + with transaction.atomic(): + code = values.get('acc_code', '').strip() + if code in seen: + raise ValidationError({'acc_code': 'کد در همین فایل تکراری است.'}) + seen.add(code) + check_category_orders(values, category_orders) + status = apply_product_row(workspace, candidate, options) + results.append(dict(row, status=status, errors={})) + except ValidationError as error: + category_orders = previous_orders + errors = error.message_dict if hasattr(error, 'message_dict') else {'row': error.messages} + results.append(dict(row, status='error', errors=errors)) + except (IntegrityError, DataError): + category_orders = previous_orders + results.append(dict(row, status='error', errors={'row': ['تعارض دیتابیس؛ اطلاعات یا کد تکراری را بررسی کنید.']})) + has_errors = any(result['status'] == 'error' for result in results) + if dry_run or (has_errors and options['errors'] == 'atomic'): + transaction.set_rollback(True) + return {'rows': results, 'errors': sum(row['status'] == 'error' for row in results), + 'created': sum(row['status'] == 'created' for row in results), + 'updated': sum(row['status'] == 'updated' for row in results), + 'committed': not dry_run and not (has_errors and options['errors'] == 'atomic')} + + +def check_category_orders(values, orders): + path = '/'.join(part.strip() for part in values.get('category', '').split('/')) + for key in ('order_cat', 'category_excel_order'): + if values.get(key): + number = integer_value(values[key], key) + previous = orders.get((path, key), number) + if previous != number: + raise ValidationError({key: 'ترتیب همین دسته در ردیف‌های فایل ناسازگار است.'}) + orders[(path, key)] = number diff --git a/apps/shop/selectors/order.py b/apps/shop/selectors/order.py index 2cec319..9a8194f 100644 --- a/apps/shop/selectors/order.py +++ b/apps/shop/selectors/order.py @@ -9,6 +9,7 @@ from apps.customer.models import Customer as CustomerModel from django.http import HttpResponse import pandas as pd from django.utils import timezone +from apps.shop.ordering import product_ordering def retrieve_order(cuser_role_uuid: str, order_id: int, workspace_uuid: str) -> OrderModel: @@ -104,20 +105,12 @@ def retrieve_order_operators(request, order_id) -> OrderModel: def retrieve_order_item_excel(request, order_id): try: workspace_uuid = get_user_workspace_uuid(request) - cuser_role_uuid = get_cuser_role_uuid(request) - customer_responsibles = CustomerResponsibleModel.objects.filter(cuser_role__uuid=cuser_role_uuid, - sys_ws__uuid=workspace_uuid) - - #responsible_group_ids = customer_responsibles.values_list('responsible_group__id', flat=True) - - #order_items = OrderItemModel.objects.filter(order_id=order_id, - # order__responsible_group_id__in=responsible_group_ids) - - - order_items = OrderItemModel.objects.filter(order_id=order_id) - - - df = pd.DataFrame(list(order_items.values('product__acc_code', 'product__category__name', 'product_name', 'amount', 'product__attr__acc_vahed'))) + OrderModel.objects.get(pk=order_id, sys_ws__uuid=workspace_uuid) + order_items = OrderItemModel.objects.filter(order_id=order_id).order_by( + *product_ordering(prefix='product__', excel=True), 'pk', + ) + columns = ['product__acc_code', 'product__category__name', 'product_name', 'amount', 'product__attr__acc_vahed'] + df = pd.DataFrame.from_records(list(order_items.values(*columns)), columns=columns) #df['واحد سنجش'] = '' df['عامل کنترل موجودی اول'] = '' diff --git a/apps/shop/services/catalog_records.py b/apps/shop/services/catalog_records.py new file mode 100644 index 0000000..b8f109c --- /dev/null +++ b/apps/shop/services/catalog_records.py @@ -0,0 +1,33 @@ +from django.core.exceptions import ValidationError as ModelValidationError +from django.db import DataError, IntegrityError +from django.shortcuts import get_object_or_404 +from rest_framework import exceptions +from apps.workspace.models import Workspace + + +def validate_catalog_save(record): + try: + record.full_clean() + record.save() + except ModelValidationError as error: + raise exceptions.ValidationError(error.message_dict if hasattr(error, 'message_dict') else error.messages) + except (DataError, IntegrityError): + raise exceptions.ValidationError('داده‌ها با محدودیت‌های دیتابیس سازگار نیستند؛ اطلاعات را تازه کنید.') + return record + + +def update_catalog_record(model, serializer_type, workspace_uuid, record_id, values): + workspace = get_object_or_404(Workspace.objects.select_for_update(), uuid=workspace_uuid) + record = get_object_or_404(model.objects.select_for_update(), pk=record_id, sys_ws=workspace) + serializer = serializer_type(record, data=values, partial=True, context={'workspace': workspace}) + serializer.is_valid(raise_exception=True) + for key, value in serializer.validated_data.items(): + setattr(record, key, value) + return validate_catalog_save(record) + + +def delete_catalog_record(model, check, workspace_uuid, record_id): + workspace = get_object_or_404(Workspace.objects.select_for_update(), uuid=workspace_uuid) + record = get_object_or_404(model.objects.select_for_update(), pk=record_id, sys_ws=workspace) + check(record) + return record.delete() diff --git a/apps/shop/services/product.py b/apps/shop/services/product.py index 107fefe..4e61d11 100644 --- a/apps/shop/services/product.py +++ b/apps/shop/services/product.py @@ -1,55 +1,29 @@ +from django.db import transaction from apps.shop.models import Product as ProductModel -from decimal import Decimal -from rest_framework import exceptions +from apps.shop.management_serializers import ProductManagementSerializer +from apps.workspace.models import Workspace -def create_product(name: str, price: Decimal, category: int, acc_code: str = None, attr: str = None, workspace=None) -> ProductModel: - try: - product_obj = ProductModel() - product_obj.name = name - product_obj.price = price - product_obj.category_id = category - if acc_code is not None: - product_obj.acc_code = acc_code - if attr is not None: - product_obj.attr = attr - product_obj.sys_ws = workspace - - product_obj.full_clean() - product_obj.save() - - return product_obj - - except Exception as exp: - message = f'Error Create Product: {exp}' - exceptions.ValidationError({'error': message}) +from apps.shop.services.catalog_records import validate_catalog_save, update_catalog_record, delete_catalog_record -def update_product(product_id: int, name: str, price: Decimal, category: int, acc_code: str = None, attr: str = None, workspace_uuid: str = None) -> ProductModel: - try: - - product_obj = ProductModel.objects.get(id=product_id, sys_ws__uuid=workspace_uuid) - - product_obj.name = name - product_obj.price = price - product_obj.category_id = category - if acc_code is not None: - product_obj.acc_code = acc_code - if attr is not None: - product_obj.attr = attr - product_obj.full_clean() - product_obj.save() - - return product_obj - except Exception as exp: - message = f'Error Updating Product: {exp}' - raise exceptions.ValidationError({'error': message}) +@transaction.atomic +def create_product(name, price, category, acc_code=None, attr=None, workspace=None, **fields): + Workspace.objects.select_for_update().get(pk=workspace.pk) + data = dict(name=name, price=price, category=category, acc_code=acc_code, attr=attr, **fields) + serializer = ProductManagementSerializer(data=data, context={'workspace': workspace}) + serializer.is_valid(raise_exception=True) + return validate_catalog_save(ProductModel(sys_ws=workspace, **serializer.validated_data)) -def delete_product(product_id: int, workspace_uuid: str) -> ProductModel: - try: - product_obj = ProductModel.objects.get(id=product_id, sys_ws__uuid=workspace_uuid).delete() - return product_obj - except Exception as exp: - message = f'Error Deleting Product: {exp}' - raise exceptions.ValidationError({'error': message}) +@transaction.atomic +def update_product(product_id, name=None, price=None, category=None, acc_code=None, attr=None, workspace_uuid=None, **fields): + values = {key: value for key, value in dict(name=name, price=price, category=category, acc_code=acc_code, attr=attr).items() if value is not None} + values.update(fields) + return update_catalog_record(ProductModel, ProductManagementSerializer, workspace_uuid, product_id, values) + + +@transaction.atomic +def delete_product(product_id, workspace_uuid): + from apps.shop.views.management import ensure_product_deletable + return delete_catalog_record(ProductModel, ensure_product_deletable, workspace_uuid, product_id) diff --git a/apps/shop/services/product_category.py b/apps/shop/services/product_category.py index a261e38..907cc1a 100644 --- a/apps/shop/services/product_category.py +++ b/apps/shop/services/product_category.py @@ -1,40 +1,29 @@ +from django.db import transaction from apps.shop.models import ProductCategory as ProductCategoryModel -from rest_framework import exceptions +from apps.shop.management_serializers import CategoryManagementSerializer +from apps.workspace.models import Workspace -def create_product_category(name: str, parent: int = None, workspace=None) -> ProductCategoryModel: - try: - product_category_obj = ProductCategoryModel(name=name, sys_ws=workspace) - - if parent is not None: - product_category_obj.parent_id = parent - - product_category_obj.full_clean() - product_category_obj.save() - - return product_category_obj - - except Exception as exp: - message = f'Error Create ProductCategory: {exp}' - exceptions.ValidationError({'error': message}) +from apps.shop.services.catalog_records import validate_catalog_save, update_catalog_record, delete_catalog_record -def update_product_category(category_id: int, name: str, workspace_uuid: str) -> ProductCategoryModel: - try: - product_category_obj = ProductCategoryModel.objects.get(id=category_id, sys_ws__uuid=workspace_uuid) - product_category_obj.name = name - product_category_obj.full_clean() - product_category_obj.save() - return product_category_obj - except Exception as exp: - message = f'Error Updating ProductCategory: {exp}' - raise exceptions.ValidationError({'error': message}) +@transaction.atomic +def create_product_category(name, parent=None, workspace=None, **fields): + Workspace.objects.select_for_update().get(pk=workspace.pk) + serializer = CategoryManagementSerializer(data=dict(name=name, parent=parent, **fields), context={'workspace': workspace}) + serializer.is_valid(raise_exception=True) + return validate_catalog_save(ProductCategoryModel(sys_ws=workspace, **serializer.validated_data)) -def delete_product_category(category_id: int, workspace_uuid: str) -> ProductCategoryModel: - try: - product_category_obj = ProductCategoryModel.objects.get(id=category_id, sys_ws__uuid=workspace_uuid).delete() - return product_category_obj - except Exception as exp: - message = f'Error Deleting ProductCategory: {exp}' - raise exceptions.ValidationError({'error': message}) +@transaction.atomic +def update_product_category(category_id, name=None, workspace_uuid=None, **fields): + values = dict(fields) + if name is not None: + values['name'] = name + return update_catalog_record(ProductCategoryModel, CategoryManagementSerializer, workspace_uuid, category_id, values) + + +@transaction.atomic +def delete_product_category(category_id, workspace_uuid): + from apps.shop.views.management import ensure_category_deletable + return delete_catalog_record(ProductCategoryModel, ensure_category_deletable, workspace_uuid, category_id) diff --git a/apps/shop/templates/admin/shop/product/change_list.html b/apps/shop/templates/admin/shop/product/change_list.html new file mode 100644 index 0000000..d0f0932 --- /dev/null +++ b/apps/shop/templates/admin/shop/product/change_list.html @@ -0,0 +1,5 @@ +{% extends "admin/change_list.html" %} +{% block object-tools-items %} +{% if can_import %}
  • ورود محصولات از اکسل
  • {% endif %} +{{ block.super }} +{% endblock %} diff --git a/apps/shop/templates/admin/shop/product/import.html b/apps/shop/templates/admin/shop/product/import.html new file mode 100644 index 0000000..02e2608 --- /dev/null +++ b/apps/shop/templates/admin/shop/product/import.html @@ -0,0 +1,20 @@ +{% extends "admin/base_site.html" %} +{% block content %} +
    +

    فهرست محصولات | دریافت فایل نمونه

    +

    تطبیق محصولات بر اساس کد در فضای کاری انتخاب‌شده است. مسیر دسته با / جدا می‌شود. فیلدهای خالی مقدار فعلی را حفظ می‌کنند؛ قیمت محصول جدید بدون قیمت صفر است. کدهای دارای صفر ابتدایی را در اکسل به صورت متن ذخیره کنید. فرمول پذیرفته نمی‌شود.

    +{% if problem %}

    {{ problem }}

    {% endif %} +{% if batch %} +

    نتیجه ورود شماره {{ batch.pk }} — {{ batch.filename }}

    +

    ایجاد: {{ result.created }} | به‌روزرسانی: {{ result.updated }} | خطا: {{ result.errors }}

    +{% if batch.confirmed_at %}

    ثبت نهایی انجام شد: {{ batch.confirmed_at }}

    {% else %}

    هنوز محصولی ثبت نشده است. اعتبار پیش‌نمایش تا {{ batch.expires_at }}.

    {% endif %} +

    دریافت گزارش همه ردیف‌ها و خطاها

    + +{% for row in result.rows|slice:':100' %}{% endfor %} +
    ردیفکدنامدستهعملیاتخطا
    {{ row.row }}{{ row.values.acc_code }}{{ row.values.name }}{{ row.values.category }}{{ row.status }}{{ row.errors }}

    در صفحه حداکثر ۱۰۰ ردیف نمایش داده می‌شود؛ گزارش اکسل شامل تمام ردیف‌هاست.

    +{% if can_confirm %}
    {% csrf_token %}
    {% endif %} +{% endif %} +

    بارگذاری و پیش‌نمایش فایل

    +
    {% csrf_token %}{{ form.as_p }}
    +
    +{% endblock %} diff --git a/apps/shop/test_admin_import.py b/apps/shop/test_admin_import.py new file mode 100644 index 0000000..d9160f0 --- /dev/null +++ b/apps/shop/test_admin_import.py @@ -0,0 +1,338 @@ +from datetime import timedelta +from io import BytesIO +from django.apps import apps +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from openpyxl import Workbook, load_workbook + +from apps.shop.import_models import ProductImportBatch +from apps.shop.models import Product, ProductCategory +from apps.shop.product_import import parse_workbook, import_products, HEADERS +from apps.workspace.models import Workspace + + +class ExcelImportTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.workspace = Workspace.objects.create(name='Main', username='main') + cls.other = Workspace.objects.create(name='Other', username='other') + cls.user = get_user_model().objects.create_user(username='staff', mobile=9123456789, password='secret', is_active=True, is_staff=True, is_superuser=True) + + def setUp(self): + self.client.force_login(self.user) + self.options = {'mode': 'upsert', 'create_categories': True, 'errors': 'atomic', 'ordering': 'preserve'} + + def row(self, code='001', name='Product', category='Parent/Child', **values): + return {'row': 2, 'values': dict(acc_code=code, name=name, category=category, **values), 'formulas': []} + + def upload(self, headers=None, data=None, name='products.xlsx'): + workbook = Workbook() + sheet = workbook.active + sheet.title = 'Products' + sheet.append(headers or ['name', 'category', 'acc_code', 'price']) + for row in data or [['Product', 'Parent/Child', '001', 1200]]: + sheet.append(row) + content = BytesIO() + workbook.save(content) + return SimpleUploadedFile(name, content.getvalue()) + + def preview(self, **overrides): + data = dict(workspace=self.workspace.pk, file=self.upload(), header_row=1, + mode='upsert', create_categories='on', errors='atomic', ordering='preserve') + data.update(overrides) + return self.client.post(reverse('admin:shop_product_import'), data) + + def confirm(self, batch): + return self.client.post(reverse('admin:shop_product_import'), {'confirm': '1', 'batch_id': batch.pk}) + + def test_parse_aliases_persian_digits_and_text_codes(self): + rows = parse_workbook(self.upload(['نام محصول', 'دسته', 'کد محصول', 'قیمت'], [['P', 'C', '0001', '۱٬۲۰۰']])) + result = import_products(self.workspace, rows, self.options) + self.assertTrue(result['committed']) + self.assertEqual(Product.objects.get().acc_code, '0001') + self.assertEqual(Product.objects.get().price, 1200) + + def test_manual_mapping_sheet_and_header_row(self): + rows = parse_workbook(self.upload(['Title', 'Group', 'Code']), 'Products', 1, {'name': 'Title', 'category': 'Group', 'acc_code': 'Code'}) + self.assertEqual(rows[0]['values']['acc_code'], '001') + with self.assertRaises(ValidationError): + parse_workbook(self.upload(), 'Absent') + + def test_duplicate_headers_mapping_and_wrong_file_rejected(self): + for file, mapping in ((self.upload(['name', 'name', 'category', 'acc_code']), {}), + (self.upload(), {'unknown': 'X'}), + (self.upload(name='bad.xls'), {}), + (SimpleUploadedFile('bad.xlsx', b'broken'), {})): + with self.assertRaises(ValidationError): + parse_workbook(file, mapping=mapping) + + def test_formula_is_row_error(self): + rows = parse_workbook(self.upload(data=[['=1+1', 'C', 'X', 1]])) + result = import_products(self.workspace, rows, self.options) + self.assertEqual(result['errors'], 1) + self.assertEqual(Product.objects.count(), 0) + + def test_dry_run_creates_no_products_or_categories(self): + result = import_products(self.workspace, [self.row()], self.options, dry_run=True) + self.assertEqual(result['created'], 1) + self.assertFalse(result['committed']) + self.assertEqual(ProductCategory.objects.count(), 0) + self.assertEqual(Product.objects.count(), 0) + + def test_nested_categories_and_independent_orders(self): + result = import_products(self.workspace, [self.row(order_prd='3', excel_order='8', order_cat='6', category_excel_order='9', unit='عدد', view_only='بله', attr='{"color": "blue"}')], self.options) + self.assertTrue(result['committed']) + product = Product.objects.get() + self.assertEqual(product.category.parent.name, 'Parent') + self.assertEqual((product.order_prd, product.excel_order, product.category.order_cat, product.category.excel_order), (3, 8, 6, 9)) + self.assertEqual(product.attr, {'color': 'blue', 'acc_vahed': 'عدد'}) + self.assertTrue(product.view_only) + + def test_atomic_errors_rollback_everything(self): + result = import_products(self.workspace, [self.row(), self.row('002', price='-1')], self.options) + self.assertFalse(result['committed']) + self.assertEqual(ProductCategory.objects.count(), 0) + self.assertEqual(Product.objects.count(), 0) + self.assertIn('price', result['rows'][1]['errors']) + + def test_skip_errors_rollback_categories_of_rejected_row(self): + self.options['errors'] = 'skip' + result = import_products(self.workspace, [self.row(), self.row('002', category='Rejected', name='X'*201)], self.options) + self.assertTrue(result['committed']) + self.assertEqual(Product.objects.count(), 1) + self.assertFalse(ProductCategory.objects.filter(name='Rejected').exists()) + + def test_duplicate_code_in_file_and_database_are_errors(self): + result = import_products(self.workspace, [self.row(), self.row()], self.options) + self.assertEqual(result['errors'], 1) + category = ProductCategory.objects.create(sys_ws=self.workspace, name='C') + for _ in range(2): + Product.objects.create(sys_ws=self.workspace, category=category, name='P', acc_code='001', price=0) + result = import_products(self.workspace, [self.row()], self.options) + self.assertEqual(result['errors'], 1) + + def test_matching_is_scoped_to_workspace(self): + import_products(self.other, [self.row()], self.options) + import_products(self.workspace, [self.row(name='Mine')], self.options) + self.assertEqual(Product.objects.count(), 2) + self.assertEqual(Product.objects.get(sys_ws=self.other).name, 'Product') + + def test_update_create_modes_and_preserve_blank(self): + import_products(self.workspace, [self.row(price='80', order_prd='4')], self.options) + self.options['mode'] = 'create' + self.assertEqual(import_products(self.workspace, [self.row()], self.options)['errors'], 1) + self.options['mode'] = 'update' + self.assertEqual(import_products(self.workspace, [self.row('missing')], self.options)['errors'], 1) + result = import_products(self.workspace, [self.row(name='Updated', price='')], self.options) + self.assertTrue(result['committed']) + self.assertEqual(Product.objects.get().price, 80) + self.assertEqual(Product.objects.get().order_prd, 4) + + def test_category_creation_disabled_and_ambiguous(self): + self.options['create_categories'] = False + self.assertEqual(import_products(self.workspace, [self.row()], self.options)['errors'], 1) + self.options['create_categories'] = True + for _ in range(2): + ProductCategory.objects.create(sys_ws=self.workspace, name='C') + self.assertEqual(import_products(self.workspace, [self.row(category='C')], self.options)['errors'], 1) + + def test_conflicting_category_orders(self): + result = import_products(self.workspace, [self.row(order_cat='1'), self.row('002', order_cat='2')], self.options) + self.assertEqual(result['errors'], 1) + self.assertFalse(result['committed']) + + def test_rows_order_and_append(self): + self.options['ordering'] = 'rows' + import_products(self.workspace, [self.row('1'), self.row('2')], self.options) + self.assertEqual(list(Product.objects.order_by('acc_code').values_list('order_prd', flat=True)), [1, 2]) + self.options['ordering'] = 'append' + import_products(self.workspace, [self.row('3')], self.options) + self.assertEqual(Product.objects.get(acc_code='3').order_prd, 3) + + def test_bad_numbers_booleans_attributes_and_category_path(self): + for values in ({'price': '1.5'}, {'price': 'NaN'}, {'view_only': 'maybe'}, {'attr': '[]'}, {'category': 'A//B'}, {'price': '999999999999999999999'}): + self.assertEqual(import_products(self.workspace, [self.row(**values)], self.options)['errors'], 1) + + def test_admin_preview_confirm_replay_and_audit(self): + response = self.preview() + self.assertEqual(response.status_code, 200) + batch = ProductImportBatch.objects.get() + self.assertEqual(Product.objects.count(), 0) + self.assertContains(response, 'ثبت نهایی') + self.assertEqual(self.confirm(batch).status_code, 200) + batch.refresh_from_db() + self.assertIsNotNone(batch.confirmed_at) + self.assertEqual(Product.objects.count(), 1) + self.assertContains(self.confirm(batch), 'ثبت نهایی انجام شد') + self.assertEqual(Product.objects.count(), 1) + from django.contrib.admin.models import LogEntry + self.assertEqual(LogEntry.objects.filter(object_repr=f'Excel import #{batch.pk}').count(), 1) + + def test_admin_expired_preview_rejected(self): + self.preview() + batch = ProductImportBatch.objects.get() + batch.expires_at = timezone.now()-timedelta(seconds=1) + batch.save() + self.assertContains(self.confirm(batch), 'منقضی') + self.assertEqual(Product.objects.count(), 0) + + def test_admin_changed_catalog_requires_new_preview(self): + self.preview() + batch = ProductImportBatch.objects.get() + ProductCategory.objects.create(sys_ws=self.workspace, name='Changed') + self.assertContains(self.confirm(batch), 'تغییر کرده') + self.assertEqual(Product.objects.count(), 0) + + def test_other_admin_cannot_confirm_or_download_batch(self): + self.preview() + batch = ProductImportBatch.objects.get() + other = get_user_model().objects.create_user(username='otherstaff', mobile=9123456790, password='secret', is_active=True, is_staff=True, is_superuser=True) + self.client.force_login(other) + self.assertEqual(self.confirm(batch).status_code, 404) + self.assertEqual(self.client.get(reverse('admin:shop_product_import_report', args=[batch.pk])).status_code, 404) + + def test_template_and_formula_safe_report(self): + response = self.client.get(reverse('admin:shop_product_import_template')) + self.assertEqual(response.status_code, 200) + sheet = load_workbook(BytesIO(response.content)).active + self.assertEqual([cell.value for cell in sheet[1]], list(HEADERS)) + self.preview(file=self.upload(data=[['=1+1', 'C', 'X', 1]])) + batch = ProductImportBatch.objects.get() + response = self.client.get(reverse('admin:shop_product_import_report', args=[batch.pk])) + sheet = load_workbook(BytesIO(response.content)).active + self.assertEqual(sheet.cell(2, 2).data_type, 's') + self.assertEqual(sheet.cell(2, 2).value, '=1+1') + + def test_nonstaff_and_staff_without_permissions_cannot_import(self): + self.user.is_superuser = False + self.user.save() + self.assertEqual(self.preview().status_code, 403) + self.assertEqual(self.client.get(reverse('admin:shop_product_import_template')).status_code, 403) + + def test_atomic_preview_cannot_be_forced_to_commit_errors(self): + self.preview(file=self.upload(data=[['Valid', 'C', '1', 10], ['Invalid', 'D', '2', -1]])) + batch = ProductImportBatch.objects.get() + self.confirm(batch) + batch.refresh_from_db() + self.assertIsNone(batch.confirmed_at) + self.assertEqual(Product.objects.count(), 0) + + def test_all_application_models_have_working_admin_lists_and_forms(self): + labels = {'shop', 'workspace', 'customer', 'cuser', 'jwt_auth', 'lead', 'ticket', 'customform', 'reminder'} + for model in apps.get_models(): + if model._meta.app_label not in labels: + continue + with self.subTest(model=model._meta.label): + self.assertIn(model, admin.site._registry) + url = reverse(f'admin:{model._meta.app_label}_{model._meta.model_name}_changelist') + self.assertEqual(self.client.get(url).status_code, 200) + model_admin = admin.site._registry[model] + from django.test import RequestFactory + request = RequestFactory().get('/') + request.user = self.user + if model_admin.has_add_permission(request): + url = reverse(f'admin:{model._meta.app_label}_{model._meta.model_name}_add') + self.assertEqual(self.client.get(url).status_code, 200) + + def test_cross_workspace_relation_and_category_cycle_rejected(self): + category = ProductCategory.objects.create(sys_ws=self.other, name='C') + response = self.client.post(reverse('admin:shop_product_add'), {'name': 'P', 'price': '1', 'category': category.pk, 'sys_ws': self.workspace.pk, 'acc_code': '1', 'view_only': '2'}) + self.assertContains(response, 'فضای کاری دیگری') + self.assertEqual(Product.objects.count(), 0) + category.parent = category + from utils.admin_base import RelatedWorkspaceForm + from django.forms import modelform_factory + form = modelform_factory(ProductCategory, form=RelatedWorkspaceForm, fields=['name', 'parent', 'sys_ws'])(data={'name': 'C', 'parent': category.pk, 'sys_ws': self.other.pk}, instance=category) + self.assertFalse(form.is_valid()) + self.assertIn('parent', form.errors) + + def test_product_export_can_be_reimported_with_category_path_and_attributes(self): + import_products(self.workspace, [self.row(price='70', attr='{"color":"blue"}', view_only='true')], self.options) + from apps.shop.admin import export_products + response = export_products(Product.objects.all()) + rows = parse_workbook(SimpleUploadedFile('export.xlsx', response.content)) + self.assertEqual(rows[0]['values']['category'], 'Parent/Child') + result = import_products(self.workspace, rows, self.options) + self.assertEqual(result['updated'], 1) + self.assertEqual(ProductCategory.objects.count(), 2) + self.assertEqual(Product.objects.get().attr, {'color': 'blue'}) + + def test_skip_rejected_category_order_does_not_poison_later_rows(self): + self.options['errors'] = 'skip' + rows = [self.row('1', name='X'*201, order_cat='1'), self.row('2', order_cat='2')] + result = import_products(self.workspace, rows, self.options) + self.assertEqual(result['errors'], 1) + self.assertEqual(Product.objects.get().category.order_cat, 2) + + def test_permissions_rechecked_after_preview(self): + self.preview() + batch = ProductImportBatch.objects.get() + self.user.is_superuser = False + self.user.save() + self.assertEqual(self.confirm(batch).status_code, 403) + self.assertEqual(Product.objects.count(), 0) + + def test_secret_tokens_and_otps_are_not_in_admin_forms_or_exports(self): + from apps.jwt_auth.models import JwtToken, CUserRoleLoginOpt + from apps.cuser.models import SmsOpt + from django.test import RequestFactory + request = RequestFactory().get('/') + request.user = self.user + for model, secrets in ((JwtToken, ('refresh', 'access')), (CUserRoleLoginOpt, ('uuid',)), (SmsOpt, ('opt',))): + model_admin = admin.site._registry[model] + form = model_admin.get_form(request) + self.assertFalse(set(secrets).intersection(form.base_fields)) + response = model_admin.export_selected(request, model.objects.none()) + headers = list(load_workbook(BytesIO(response.content)).active.values)[0] + self.assertFalse(set(secrets).intersection(headers)) + + +from apps.jwt_auth.test_regressions import RegressionFixtures + + +class LegacyProductImportTests(RegressionFixtures): + def admin_login(self): + from apps.cuser.models import CuserRole + role = CuserRole.objects.create(cuser=self.user, role_type=self.admin_type, sys_ws=self.workspace, + name_family='Admin', mobile=self.user.mobile, is_active=True, mobile_verify=True) + self.use_role(role) + + def workbook_content(self, data): + book = Workbook() + sheet = book.active + sheet.append(['name', 'cate_name', 'acc_code', 'size', 'vahed']) + for row in data: + sheet.append(row) + output = BytesIO() + book.save(output) + return output.getvalue() + + def test_legacy_api_uses_shared_validation_and_rolls_back(self): + from unittest.mock import patch + self.admin_login() + content = self.workbook_content([['P', 'C', '1', 1, 'عدد'], ['Bad', 'D', '2', -1, 'عدد']]) + with patch('apps.shop.views.excel_import.open', return_value=BytesIO(content), create=True): + response = self.client.get('/shop/import/') + self.assertEqual(response.status_code, 400) + self.assertEqual(Product.objects.count(), 0) + self.assertEqual(ProductCategory.objects.count(), 0) + + def test_legacy_api_import_still_works_and_scopes_workspace(self): + from unittest.mock import patch + self.admin_login() + content = self.workbook_content([['P', 'C', '0001', 1, 'عدد']]) + with patch('apps.shop.views.excel_import.open', return_value=BytesIO(content), create=True): + response = self.client.get('/shop/import/') + self.assertEqual(response.status_code, 200) + self.assertEqual(Product.objects.get().acc_code, '0001') + self.assertEqual(Product.objects.get().sys_ws, self.workspace) + self.assertTrue(Product.objects.get().view_only) + + def test_legacy_api_requires_admin_role(self): + self.use_role() + self.assertEqual(self.client.get('/shop/import/').status_code, 403) diff --git a/apps/shop/test_management.py b/apps/shop/test_management.py new file mode 100644 index 0000000..e0792ef --- /dev/null +++ b/apps/shop/test_management.py @@ -0,0 +1,289 @@ +from datetime import timedelta +from io import BytesIO +from django.core.files.uploadedfile import SimpleUploadedFile +from django.utils import timezone +from openpyxl import Workbook, load_workbook +from apps.jwt_auth.test_regressions import RegressionFixtures +from apps.cuser.models import CuserRole, RoleType +from apps.shop.models import Product, ProductCategory, Order, OrderItem, Cart +from apps.shop.import_models import ProductImportBatch + +ROOT = '/shop/manage/' + + +class ProductManagementTests(RegressionFixtures): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.admin_role = CuserRole.objects.create(cuser=cls.user, role_type=cls.admin_type, sys_ws=cls.workspace, + name_family='Admin', mobile=cls.user.mobile, is_active=True, mobile_verify=True) + cls.category = ProductCategory.objects.create(sys_ws=cls.workspace, name='C', order_cat=1) + cls.foreign_category = ProductCategory.objects.create(sys_ws=cls.other_workspace, name='Foreign') + cls.product = Product.objects.create(sys_ws=cls.workspace, category=cls.category, name='P', acc_code='P1', price=10) + cls.foreign_product = Product.objects.create(sys_ws=cls.other_workspace, category=cls.foreign_category, name='Foreign', acc_code='F1', price=20) + + def setUp(self): + self.use_role(self.admin_role) + + def payload(self, **values): + return dict(name='New product', price='100', category=self.category.pk, acc_code='NEW', **values) + + def patch(self, path, data): + return self.client.patch(ROOT+path, data, format='json') + + def upload(self, rows=None): + book = Workbook() + sheet = book.active + sheet.append(['name', 'category', 'acc_code', 'price']) + for row in rows or [['Imported', 'Root/Child', 'EXCEL1', 50]]: + sheet.append(row) + output = BytesIO() + book.save(output) + return SimpleUploadedFile('products.xlsx', output.getvalue()) + + def preview(self, **values): + data = dict(file=self.upload(), mode='upsert', create_categories='true', errors='atomic', ordering='preserve') + data.update(values) + return self.client.post(ROOT+'import/preview/', data, format='multipart') + + def test_operator_customer_even_flagged_admin_denied_every_management_endpoint(self): + paths = [('get', 'products/'), ('post', 'products/'), ('get', f'products/{self.product.pk}/'), + ('patch', f'products/{self.product.pk}/'), ('delete', f'products/{self.product.pk}/'), + ('post', 'products/bulk/'), ('get', 'products/export/'), ('get', 'categories/'), + ('post', 'categories/'), ('patch', f'categories/{self.category.pk}/'), + ('delete', f'categories/{self.category.pk}/'), ('post', 'import/preview/'), + ('get', 'import/template/'), ('get', 'imports/'), ('get', 'imports/999/'), + ('post', 'imports/999/confirm/'), ('get', 'imports/999/report/')] + for role in (self.role, self.operator): + role.is_admin = True + role.save() + self.use_role(role) + for method, path in paths: + with self.subTest(role=role.role_type.role_name_en, method=method, path=path): + response = getattr(self.client, method)(ROOT+path) + self.assertEqual(response.status_code, 403) + + def test_unauthenticated_and_revoked_session_cannot_manage(self): + self.client.credentials() + self.assertEqual(self.client.get(ROOT+'products/').status_code, 401) + self.use_role(self.admin_role) + from apps.jwt_auth.models import JwtToken + JwtToken.objects.update(is_blocked=True) + self.assertEqual(self.client.get(ROOT+'products/').status_code, 401) + + def test_capabilities_match_current_role_and_owner(self): + self.assertTrue(self.client.get('/cuser/info/').data['permissions']['manage_products']) + self.use_role(self.operator) + self.assertFalse(self.client.get('/cuser/info/').data['permissions']['manage_products']) + owner_type = RoleType.objects.create(role_name='Owner', role_name_en='workspace_owner') + owner = CuserRole.objects.create(cuser=self.user, role_type=owner_type, sys_ws=self.workspace, name_family='Owner', mobile=self.user.mobile, is_active=True, mobile_verify=True) + self.use_role(owner) + self.assertEqual(self.client.get(ROOT+'products/').status_code, 200) + + def test_product_list_paginated_filtered_and_workspace_scoped(self): + result = self.client.get(ROOT+'products/').data + self.assertEqual(result['count'], 1) + self.assertEqual(result['results'][0]['id'], self.product.pk) + self.assertEqual(self.client.get(ROOT+'products/?search=Foreign').data['count'], 0) + self.assertEqual(self.client.get(ROOT+'products/?search=P1&price_min=10&price_max=10').data['count'], 1) + self.assertEqual(self.client.get(ROOT+'products/?view_only=true').data['count'], 0) + self.assertEqual(self.client.get(ROOT+'products/?price_min=20&price_max=1').status_code, 400) + self.assertEqual(self.client.get(ROOT+'products/?ordering=unknown').status_code, 400) + + def test_categories_include_empty_and_counts(self): + empty = ProductCategory.objects.create(sys_ws=self.workspace, name='Empty', parent=self.category) + response = self.client.get(ROOT+'categories/') + self.assertEqual(response.status_code, 200) + rows = {row['id']: row for row in response.data} + self.assertNotIn(self.foreign_category.pk, rows) + self.assertEqual(rows[self.category.pk]['product_count'], 1) + self.assertEqual(rows[self.category.pk]['child_count'], 1) + self.assertEqual(rows[empty.pk]['product_count'], 0) + + def test_product_crud_attributes_orders_and_big_price_precision(self): + data = self.payload() + data.update(name='N'*150, price='9223372036854775807', attr={'unit': 'عدد'}, order_prd=3, excel_order=7) + response = self.client.post(ROOT+'products/', data, format='json') + self.assertEqual(response.status_code, 201, response.data) + self.assertEqual(response.data['price'], '9223372036854775807') + product_id = response.data['id'] + response = self.patch(f'products/{product_id}/', {'price': '0', 'order_prd': None, 'attr': None, 'view_only': True}) + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data['order_prd']) + self.assertIsNone(response.data['attr']) + self.assertTrue(response.data['view_only']) + self.assertEqual(self.client.delete(ROOT+f'products/{product_id}/').status_code, 204) + + def test_product_duplicate_code_invalid_values_and_foreign_category(self): + for values in ({'acc_code': 'P1'}, {'category': self.foreign_category.pk}, {'price': -1}, {'price': '1.5'}, {'price': '9223372036854775808'}, {'attr': []}, {'order_prd': -1}): + data = self.payload() + data.update(values) + response = self.client.post(ROOT+'products/', data, format='json') + self.assertEqual(response.status_code, 400, response.data) + self.assertEqual(Product.objects.count(), 2) + + def test_foreign_records_not_read_changed_or_deleted(self): + for path in (f'products/{self.foreign_product.pk}/', f'categories/{self.foreign_category.pk}/'): + self.assertEqual(self.client.get(ROOT+path).status_code, 404) + self.assertEqual(self.patch(path, {'name': 'Stolen'}).status_code, 404) + self.assertEqual(self.client.delete(ROOT+path).status_code, 404) + self.foreign_product.refresh_from_db() + self.assertEqual(self.foreign_product.name, 'Foreign') + + def test_category_crud_duplicate_and_foreign_parent(self): + response = self.client.post(ROOT+'categories/', {'name': 'New category', 'parent': self.category.pk, 'order_cat': 2, 'excel_order': 9}, format='json') + self.assertEqual(response.status_code, 201) + pk = response.data['id'] + self.assertEqual(self.client.post(ROOT+'categories/', {'name': 'New category', 'parent': self.category.pk}, format='json').status_code, 400) + self.assertEqual(self.patch(f'categories/{pk}/', {'parent': self.foreign_category.pk}).status_code, 400) + response = self.patch(f'categories/{pk}/', {'name': 'Renamed', 'parent': None, 'order_cat': None}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['name'], 'Renamed') + self.assertEqual(self.client.delete(ROOT+f'categories/{pk}/').status_code, 204) + + def test_category_cycles_and_nonempty_deletion_blocked(self): + child = ProductCategory.objects.create(sys_ws=self.workspace, name='Child', parent=self.category) + self.assertEqual(self.patch(f'categories/{self.category.pk}/', {'parent': child.pk}).status_code, 400) + self.assertEqual(self.patch(f'categories/{child.pk}/', {'parent': child.pk}).status_code, 400) + self.assertEqual(self.client.delete(ROOT+f'categories/{self.category.pk}/').status_code, 409) + self.assertTrue(Product.objects.filter(pk=self.product.pk).exists()) + + def test_product_with_order_or_cart_cannot_delete(self): + order = Order.objects.create(sys_ws=self.workspace, customer=self.customer, total_price=10, factor_type=1) + OrderItem.objects.create(order=order, product=self.product, product_name='P', price=10, amount=1) + self.assertEqual(self.client.delete(ROOT+f'products/{self.product.pk}/').status_code, 409) + self.assertTrue(OrderItem.objects.filter(order=order).exists()) + OrderItem.objects.all().delete() + Cart.objects.create(sys_ws=self.workspace, product=self.product, cuser_role=self.role, quantity=1) + self.assertEqual(self.client.delete(ROOT+f'products/{self.product.pk}/').status_code, 409) + + def test_bulk_move_toggle_and_reorder(self): + second = Product.objects.create(sys_ws=self.workspace, category=self.category, name='Second', price=0) + target = ProductCategory.objects.create(sys_ws=self.workspace, name='Target') + for action, fields in (('move', {'category': target.pk}), ('view_only', {'view_only': True}), ('excel_order', {})): + response = self.client.post(ROOT+'products/bulk/', dict(ids=[second.pk, self.product.pk], action=action, **fields), format='json') + self.assertEqual(response.status_code, 200, response.data) + self.product.refresh_from_db() + self.assertEqual(self.product.category, target) + self.assertTrue(self.product.view_only) + self.assertEqual(self.product.excel_order, 2) + + def test_bulk_foreign_or_duplicate_ids_roll_back(self): + for ids in ([self.product.pk, self.foreign_product.pk], [self.product.pk, self.product.pk]): + response = self.client.post(ROOT+'products/bulk/', {'ids': ids, 'action': 'view_only', 'view_only': True}, format='json') + self.assertEqual(response.status_code, 400) + self.product.refresh_from_db() + self.assertFalse(self.product.view_only) + + def test_bulk_deletion_all_or_nothing(self): + removable = Product.objects.create(sys_ws=self.workspace, category=self.category, name='Removable', price=0) + Cart.objects.create(sys_ws=self.workspace, product=self.product, cuser_role=self.role, quantity=1) + response = self.client.post(ROOT+'products/bulk/', {'ids': [removable.pk, self.product.pk], 'action': 'delete'}, format='json') + self.assertEqual(response.status_code, 409) + self.assertTrue(Product.objects.filter(pk=removable.pk).exists()) + + def test_legacy_category_update_fixed_and_cross_workspace_product_rejected(self): + response = self.client.put(f'/shop/product-category/update/{self.category.pk}/', {'name': 'Fixed'}, format='json') + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data['name'], 'Fixed') + response = self.client.post('/shop/product/create/', {'name': 'X', 'price': 1, 'category': self.foreign_category.pk}, format='json') + self.assertEqual(response.status_code, 400) + self.assertEqual(self.client.delete(f'/shop/product-category/delete/{self.category.pk}/').status_code, 409) + + def test_excel_template_export_is_workspace_scoped_and_formula_safe(self): + self.product.name = '=1+1' + self.product.save() + response = self.client.get(ROOT+'products/export/?search=P1') + self.assertEqual(response.status_code, 200) + sheet = load_workbook(BytesIO(response.content)).active + self.assertEqual(sheet.cell(2, 3).data_type, 's') + self.assertEqual(sheet.max_row, 2) + self.assertEqual(self.client.get(ROOT+'import/template/').status_code, 200) + + def test_import_preview_confirm_replay_and_report(self): + response = self.preview() + self.assertEqual(response.status_code, 201, response.data) + batch_id = response.data['id'] + self.assertFalse(response.data['committed']) + self.assertFalse(Product.objects.filter(acc_code='EXCEL1').exists()) + self.assertEqual(self.client.get(ROOT+'imports/').data['count'], 1) + self.assertEqual(self.client.get(ROOT+f'imports/{batch_id}/report/').status_code, 200) + for _ in range(2): + response = self.client.post(ROOT+f'imports/{batch_id}/confirm/', {}, format='json') + self.assertEqual(response.status_code, 200, response.data) + self.assertTrue(response.data['committed']) + self.assertEqual(Product.objects.filter(acc_code='EXCEL1').count(), 1) + + def test_import_atomic_and_skip_modes(self): + bad = [['Good', 'Valid', 'GOOD', 1], ['Bad', 'Invalid', 'BAD', -1]] + response = self.preview(file=self.upload(bad)) + self.assertEqual(response.data['errors'], 1) + self.assertFalse(response.data['can_confirm']) + batch_id = response.data['id'] + self.client.post(ROOT+f'imports/{batch_id}/confirm/') + self.assertFalse(Product.objects.filter(acc_code='GOOD').exists()) + response = self.preview(file=self.upload(bad), errors='skip') + batch_id = response.data['id'] + result = self.client.post(ROOT+f'imports/{batch_id}/confirm/').data + self.assertTrue(result['committed']) + self.assertEqual(result['errors'], 1) + self.assertTrue(Product.objects.filter(acc_code='GOOD').exists()) + self.assertFalse(ProductCategory.objects.filter(name='Invalid').exists()) + + def test_import_wrong_options_mapping_or_file_returns_error(self): + for values in ({'mode': 'unknown'}, {'mapping': '[]'}, {'header_row': 0}, {'file': SimpleUploadedFile('bad.xlsx', b'bad')}): + response = self.preview(**values) + self.assertEqual(response.status_code, 400, response.data) + + def test_import_expired_or_changed_catalog_cannot_confirm(self): + for expired in (True, False): + batch_id = self.preview().data['id'] + if expired: + ProductImportBatch.objects.filter(pk=batch_id).update(expires_at=timezone.now()-timedelta(seconds=1)) + else: + self.product.name = 'Changed' + self.product.save() + self.assertEqual(self.client.post(ROOT+f'imports/{batch_id}/confirm/').status_code, 400) + self.assertFalse(Product.objects.filter(acc_code='EXCEL1').exists()) + + def test_import_other_workspace_and_other_owner_hidden(self): + batch_id = self.preview().data['id'] + self.admin_role.sys_ws = self.other_workspace + self.admin_role.save() + self.use_role(self.admin_role) + for suffix in ('', 'report/', 'confirm/'): + method = self.client.post if suffix == 'confirm/' else self.client.get + self.assertEqual(method(ROOT+f'imports/{batch_id}/'+suffix).status_code, 404) + self.assertEqual(self.client.get(ROOT+'imports/').data['count'], 0) + + def test_import_rows_paginate_without_losing_report_rows(self): + rows = [[f'P{i}', 'C', f'CODE{i}', 1] for i in range(101)] + response = self.preview(file=self.upload(rows)) + self.assertEqual(len(response.data['rows']), 100) + self.assertEqual(response.data['row_count'], 101) + batch_id = response.data['id'] + self.assertEqual(len(self.client.get(ROOT+f'imports/{batch_id}/?row_page=2').data['rows']), 1) + self.assertEqual(self.client.get(ROOT+f'imports/{batch_id}/?row_page=0').status_code, 400) + + def test_category_multipart_image_and_clearing_nullable_fields(self): + from tempfile import TemporaryDirectory + from django.test import override_settings + from PIL import Image + content = BytesIO() + Image.new('RGB', (2, 2), color='blue').save(content, format='PNG') + with TemporaryDirectory(dir='../../reports/product-management') as directory, override_settings(MEDIA_ROOT=directory): + upload = SimpleUploadedFile('category.png', content.getvalue(), content_type='image/png') + response = self.client.post(ROOT+'categories/', {'name': 'Image', 'parent': '', 'order_cat': '', 'excel_order': '', 'image': upload}, format='multipart') + self.assertEqual(response.status_code, 201, response.data) + self.assertIsNone(response.data['order_cat']) + pk = response.data['id'] + self.assertTrue(response.data['image'].endswith('.png')) + response = self.patch(f'categories/{pk}/', {'image': None, 'order_cat': 4}) + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data['image']) + + def test_category_negative_and_out_of_range_orders_are_rejected(self): + for values in ({'order_cat': -1}, {'excel_order': -1}, {'order_cat': 2147483648}): + data = {'name': 'Invalid'} + data.update(values) + self.assertEqual(self.client.post(ROOT+'categories/', data, format='json').status_code, 400) diff --git a/apps/shop/urls.py b/apps/shop/urls.py index d3b2ad6..4c5f26e 100644 --- a/apps/shop/urls.py +++ b/apps/shop/urls.py @@ -1,7 +1,25 @@ from django.urls import path from apps.shop.views import * +from apps.shop.views.management import ( + ProductManagementListApi, ProductManagementDetailApi, CategoryManagementListApi, + CategoryManagementDetailApi, ProductBulkApi, ProductExportApi, + ProductImportPreviewApi, ProductImportListApi, ProductImportDetailApi, + ProductImportConfirmApi, ProductImportReportApi, ProductImportTemplateApi, +) urlpatterns = [ + path('manage/products/', ProductManagementListApi.as_view()), + path('manage/products/bulk/', ProductBulkApi.as_view()), + path('manage/products/export/', ProductExportApi.as_view()), + path('manage/products//', ProductManagementDetailApi.as_view()), + path('manage/categories/', CategoryManagementListApi.as_view()), + path('manage/categories//', CategoryManagementDetailApi.as_view()), + path('manage/import/preview/', ProductImportPreviewApi.as_view()), + path('manage/import/template/', ProductImportTemplateApi.as_view()), + path('manage/imports/', ProductImportListApi.as_view()), + path('manage/imports//', ProductImportDetailApi.as_view()), + path('manage/imports//confirm/', ProductImportConfirmApi.as_view()), + path('manage/imports//report/', ProductImportReportApi.as_view()), path('product-category/create/', ProductCategoryCreateApi.as_view(), name='create_product_category'), path('product-category//', ProductCategoryRetrieveApi.as_view(), name='retrieve_product_category'), path('product-category/update//', ProductCategoryUpdateApi.as_view(), name='update_product_category'), diff --git a/apps/shop/views/excel_import.py b/apps/shop/views/excel_import.py index 3cbe937..c8ce187 100644 --- a/apps/shop/views/excel_import.py +++ b/apps/shop/views/excel_import.py @@ -5,45 +5,34 @@ from apps.shop.models import Product as ProductModel, ProductCategory as Product from apps.workspace.models import Workspace as WorkspaceModel, City as CityModel from apps.customer.models import Customer as CustomerModel from apps.customer.models import ResponsibleGroup as ResponsibleGroupModel -import os +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import IntegrityError, transaction +from rest_framework import exceptions +from rest_framework.permissions import IsAuthenticated +from utils import custom_permissions +from utils.get_user_workspace import get_user_workspace +from utils.validations.mobile import normalize_mobile class ImportExcelView(APIView): + permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission) def get(self, request): - - df = pd.read_excel('pe.xlsx') - for index, row in df.iterrows(): - cate_name = row['cate_name'] - #order_cat = row['order_cat'] - acc_code = row['acc_code'] - prd_name = row['name'] - order_prd = row['size'] - vahed = row['vahed'] - - workspace = WorkspaceModel.objects.get(id=2) - try: - prd_ct = ProductCategoryModel.objects.get(name=cate_name, sys_ws=workspace) - except ProductCategoryModel.DoesNotExist as exp: - prd_ct = ProductCategoryModel.objects.create(name=cate_name, sys_ws=workspace) - - if ProductModel.objects.filter(acc_code=acc_code).exists() == False: - new_product = ProductModel() - new_product.name = prd_name - new_product.price = 0 - new_product.category = ProductCategoryModel.objects.get(id=prd_ct.id) - new_product.acc_code = acc_code - new_product.order_prd = order_prd - new_product.view_only = True - new_product.attr = {"acc_vahed": vahed} - new_product.sys_ws = workspace - new_product.save() - else: - up_product = ProductModel.objects.get(acc_code=acc_code) - up_product.name = prd_name - up_product.save() - - return Response(data={"result": 'ok'}) + # Keep the legacy URL and filename, but use the same validated importer as Admin. + from django.core.files import File + from apps.shop.product_import import parse_workbook, import_products + try: + with open('pe.xlsx', 'rb') as source: + rows = parse_workbook(File(source, name='pe.xlsx')) + for row in rows: + row['values'].setdefault('view_only', 'true') + result = import_products(get_user_workspace(request), rows, + {'mode': 'upsert', 'create_categories': True, 'errors': 'atomic', 'ordering': 'preserve'}) + except (OSError, DjangoValidationError) as error: + raise exceptions.ValidationError(getattr(error, 'messages', ['فایل اکسل محصولات خوانده نشد.'])) + if not result['committed']: + raise exceptions.ValidationError(result) + return Response(data={'result': 'ok', 'imported': result['created'] + result['updated']}) class ImportExcelCitysView(APIView): @@ -72,43 +61,46 @@ class ImportExcelCitysView(APIView): return Response(data={"result": 'ok'}) +def customer_from_excel(row, workspace): + mobile = row['mobile'] + if pd.isna(mobile) or pd.isna(row['name']): + raise exceptions.ValidationError('نام و شماره موبایل مشتری الزامی است.') + if isinstance(mobile, float) and mobile.is_integer(): + mobile = int(mobile) + city = CityModel.objects.filter(name=row['shar'], ostan__name=row['ostan'], ostan__is_ostan=True).first() + city = city or CityModel.objects.filter(pk=1229).first() + if not city: + raise exceptions.ValidationError('شهر مشتری در فایل پیدا نشد.') + group = ResponsibleGroupModel.objects.filter(pk=row['id_mantaghe'], sys_ws=workspace).first() + if not group: + raise exceptions.ValidationError('گروه مسئول متعلق به این فضای کاری نیست.') + customer = CustomerModel(name_family=row['name'], mobile=normalize_mobile(mobile), city=city, + responsible_group=group, sys_ws=workspace, + phone=None if pd.isna(row['phone']) else row['phone'], + address=None if pd.isna(row['address']) else row['address']) + customer.full_clean() + customer.save() + + class ImportExcelCustomersView(APIView): + permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission) + @transaction.atomic def get(self, request): - - df = pd.read_excel('customersData.xlsx') - for index, row in df.iterrows(): - name = row['name'] - mobile = row['mobile'] - - if name and mobile: - - phone = row['phone'] - address = row['address'] - ostan = row['ostan'] - shahr = row['shar'] - mantaghe = row['id_mantaghe'] - - try: - ostan_obj = CityModel.objects.get(name=ostan, is_ostan=True) - shahr_obj = CityModel.objects.get(name=shahr, ostan=ostan_obj) - except Exception as exp: - shahr_obj = CityModel.objects.get(id=1229) - - try: - cs = CustomerModel() - cs.name_family = name - cs.mobile = mobile - if phone: - cs.phone = phone - - cs.city = shahr_obj - if address: - cs.address = address - cs.responsible_group = ResponsibleGroupModel.objects.get(id=mantaghe) - cs.sys_ws = WorkspaceModel.objects.get(id=2) - cs.save() - except Exception as exp: - print(name) - - return Response(data={"result": 'ok'}) + workspace = get_user_workspace(request) + try: + data = pd.read_excel('customersData.xlsx') + except (OSError, ValueError): + raise exceptions.ValidationError('فایل اکسل مشتریان خوانده نشد.') + for index, row in data.iterrows(): + try: + customer_from_excel(row, workspace) + except DjangoValidationError as error: + raise exceptions.ValidationError({'row': int(index)+2, 'errors': error.message_dict}) + except IntegrityError: + raise exceptions.ValidationError({'row': int(index)+2, 'mobile': 'شماره موبایل مشتری تکراری است.'}) + except exceptions.ValidationError as error: + raise exceptions.ValidationError({'row': int(index)+2, 'errors': error.detail}) + except (KeyError, TypeError, ValueError): + raise exceptions.ValidationError({'row': int(index)+2, 'error': 'اطلاعات این ردیف معتبر نیست.'}) + return Response(data={'result': 'ok', 'imported': len(data)}) diff --git a/apps/shop/views/management.py b/apps/shop/views/management.py new file mode 100644 index 0000000..64781d5 --- /dev/null +++ b/apps/shop/views/management.py @@ -0,0 +1,293 @@ +"""JWT-protected workspace administration for the CRM product pages.""" +from django.core.exceptions import ValidationError as ModelValidationError +from django.db import DataError, IntegrityError, transaction +from django.db.models import Count, Q +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import serializers +from rest_framework.exceptions import APIException, ValidationError +from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.shop.import_batches import preview_batch, confirm_batch, batch_report, import_template +from apps.shop.import_models import ProductImportBatch +from apps.shop.management_serializers import ProductManagementSerializer, CategoryManagementSerializer, ImportOptionsSerializer +from apps.shop.models import Product, ProductCategory, Cart, OrderItem +from apps.shop.ordering import product_ordering +from apps.workspace.models import Workspace +from utils.custom_permissions import WorkspaceAdminPermission +from utils.get_user_workspace import get_user_workspace + + +class CatalogConflict(APIException): + status_code = 409 + default_detail = 'این عملیات با داده‌های فعلی سازگار نیست.' + + +def ensure_product_deletable(product): + if OrderItem.objects.filter(product=product).exists() or Cart.objects.filter(product=product).exists(): + raise CatalogConflict('محصول در سفارش یا سبد استفاده شده است؛ برای حفظ سوابق امکان حذف ندارد.') + + +def ensure_category_deletable(category): + if Product.objects.filter(category=category).exists() or ProductCategory.objects.filter(parent=category).exists(): + raise CatalogConflict('دسته دارای محصول یا زیردسته است؛ ابتدا محصولات و زیردسته‌ها را منتقل کنید.') + + +class ManagementPagination(PageNumberPagination): + page_size = 25 + page_size_query_param = 'page_size' + max_page_size = 100 + + +class ManagementApi(APIView): + permission_classes = (IsAuthenticated, WorkspaceAdminPermission) + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + self.workspace = get_user_workspace(request) + + def handle_exception(self, exc): + if isinstance(exc, ModelValidationError): + exc = ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else {'detail': exc.messages}) + elif isinstance(exc, (DataError, IntegrityError)): + exc = CatalogConflict('داده‌ها با محدودیت‌های دیتابیس سازگار نیستند یا هم‌زمان تغییر کرده‌اند؛ اطلاعات را تازه کنید.') + return super().handle_exception(exc) + + def serializer_context(self): + return {'request': self.request, 'workspace': self.workspace} + + def lock_workspace(self): + Workspace.objects.select_for_update().get(pk=self.workspace.pk) + + +class ProductFilters(serializers.Serializer): + search = serializers.CharField(required=False, allow_blank=True, max_length=200) + category = serializers.IntegerField(required=False, min_value=1) + view_only = serializers.BooleanField(required=False) + price_min = serializers.IntegerField(required=False, min_value=0) + price_max = serializers.IntegerField(required=False, min_value=0) + ordering = serializers.ChoiceField(choices=('display', 'excel', 'name', '-name', 'price', '-price', '-created_at'), default='display') + + def validate(self, attrs): + if attrs.get('price_min', 0) > attrs.get('price_max', 9223372036854775807): + raise ValidationError({'price_max': 'حداکثر قیمت باید از حداقل کمتر نباشد.'}) + return attrs + + +def filtered_products(workspace, parameters): + serializer = ProductFilters(data=parameters) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + queryset = Product.objects.filter(sys_ws=workspace).select_related('category') + if data.get('search'): + queryset = queryset.filter(Q(name__icontains=data['search']) | Q(acc_code__icontains=data['search']) | Q(category__name__icontains=data['search'])) + if 'category' in data: + queryset = queryset.filter(category_id=data['category']) + if 'view_only' in data: + queryset = queryset.filter(view_only=data['view_only']) + for key, lookup in (('price_min', 'price__gte'), ('price_max', 'price__lte')): + if key in data: + queryset = queryset.filter(**{lookup: data[key]}) + ordering = data['ordering'] + return queryset.order_by(*product_ordering(excel=ordering == 'excel')) if ordering in ('display', 'excel') else queryset.order_by(ordering, 'pk') + + +class ProductManagementListApi(ManagementApi): + def get(self, request): + pagination = ManagementPagination() + rows = pagination.paginate_queryset(filtered_products(self.workspace, request.query_params), request) + return pagination.get_paginated_response(ProductManagementSerializer(rows, many=True).data) + + @transaction.atomic + def post(self, request): + self.lock_workspace() + serializer = ProductManagementSerializer(data=request.data, context=self.serializer_context()) + serializer.is_valid(raise_exception=True) + product = Product(sys_ws=self.workspace, **serializer.validated_data) + product.full_clean() + product.save() + return Response(ProductManagementSerializer(product).data, status=201) + + +class ProductManagementDetailApi(ManagementApi): + def get(self, request, pk): + product = get_object_or_404(Product, pk=pk, sys_ws=self.workspace) + return Response(ProductManagementSerializer(product).data) + + @transaction.atomic + def patch(self, request, pk): + self.lock_workspace() + product = get_object_or_404(Product.objects.select_for_update(), pk=pk, sys_ws=self.workspace) + serializer = ProductManagementSerializer(product, data=request.data, partial=True, context=self.serializer_context()) + serializer.is_valid(raise_exception=True) + for name, value in serializer.validated_data.items(): + setattr(product, name, value) + product.full_clean() + product.save() + return Response(ProductManagementSerializer(product).data) + + put = patch + + @transaction.atomic + def delete(self, request, pk): + self.lock_workspace() + product = get_object_or_404(Product.objects.select_for_update(), pk=pk, sys_ws=self.workspace) + ensure_product_deletable(product) + product.delete() + return Response(status=204) + + +class CategoryManagementListApi(ManagementApi): + def get(self, request): + queryset = ProductCategory.objects.filter(sys_ws=self.workspace).annotate(product_count=Count('product', distinct=True), child_count=Count('productcategory', distinct=True)) + return Response(CategoryManagementSerializer(queryset, many=True, context=self.serializer_context()).data) + + @transaction.atomic + def post(self, request): + self.lock_workspace() + serializer = CategoryManagementSerializer(data=request.data, context=self.serializer_context()) + serializer.is_valid(raise_exception=True) + category = ProductCategory(sys_ws=self.workspace, **serializer.validated_data) + category.full_clean() + category.save() + return Response(CategoryManagementSerializer(category, context=self.serializer_context()).data, status=201) + + +class CategoryManagementDetailApi(ManagementApi): + def get(self, request, pk): + category = get_object_or_404(ProductCategory, pk=pk, sys_ws=self.workspace) + return Response(CategoryManagementSerializer(category, context=self.serializer_context()).data) + + @transaction.atomic + def patch(self, request, pk): + self.lock_workspace() + category = get_object_or_404(ProductCategory.objects.select_for_update(), pk=pk, sys_ws=self.workspace) + serializer = CategoryManagementSerializer(category, data=request.data, partial=True, context=self.serializer_context()) + serializer.is_valid(raise_exception=True) + for name, value in serializer.validated_data.items(): + setattr(category, name, value) + category.full_clean() + category.save() + return Response(CategoryManagementSerializer(category, context=self.serializer_context()).data) + + put = patch + + @transaction.atomic + def delete(self, request, pk): + self.lock_workspace() + category = get_object_or_404(ProductCategory.objects.select_for_update(), pk=pk, sys_ws=self.workspace) + ensure_category_deletable(category) + category.delete() + return Response(status=204) + + +class ProductBulkSerializer(serializers.Serializer): + ids = serializers.ListField(child=serializers.IntegerField(min_value=1), min_length=1, max_length=100) + action = serializers.ChoiceField(choices=('delete', 'move', 'view_only', 'display_order', 'excel_order')) + category = serializers.IntegerField(min_value=1, required=False) + view_only = serializers.BooleanField(required=False) + + def validate(self, attrs): + if len(set(attrs['ids'])) != len(attrs['ids']): + raise ValidationError({'ids': 'شناسه‌های تکراری مجاز نیست.'}) + for action, key in (('move', 'category'), ('view_only', 'view_only')): + if attrs['action'] == action and key not in attrs: + raise ValidationError({key: 'این مقدار برای عملیات انتخاب‌شده لازم است.'}) + return attrs + + +class ProductBulkApi(ManagementApi): + @transaction.atomic + def post(self, request): + self.lock_workspace() + input_serializer = ProductBulkSerializer(data=request.data) + input_serializer.is_valid(raise_exception=True) + data = input_serializer.validated_data + products = {item.pk: item for item in Product.objects.select_for_update().filter(sys_ws=self.workspace, pk__in=data['ids'])} + if len(products) != len(data['ids']): + raise ValidationError({'ids': 'یک یا چند محصول در فضای کاری شما پیدا نشد.'}) + for position, pk in enumerate(data['ids'], 1): + product = products[pk] + if data['action'] == 'delete': + ensure_product_deletable(product) + product.delete() + else: + values = bulk_values(data, position) + serializer = ProductManagementSerializer(product, data=values, partial=True, context=self.serializer_context()) + serializer.is_valid(raise_exception=True) + for name, value in serializer.validated_data.items(): + setattr(product, name, value) + product.full_clean() + product.save() + return Response({'updated': len(products), 'action': data['action']}) + + +def bulk_values(data, position): + if data['action'] == 'move': + return {'category': data['category']} + if data['action'] == 'view_only': + return {'view_only': data['view_only']} + return {'order_prd' if data['action'] == 'display_order' else 'excel_order': position} + + +class ProductExportApi(ManagementApi): + def get(self, request): + from apps.shop.admin import export_products + return export_products(filtered_products(self.workspace, request.query_params)) + + +def batch_data(batch, page=1): + result = batch.result + rows = result.get('rows', []) + return {'id': batch.pk, 'filename': batch.filename, 'created_at': batch.created_at, 'expires_at': batch.expires_at, + 'confirmed_at': batch.confirmed_at, 'options': batch.payload['options'], + 'created': result.get('created', 0), 'updated': result.get('updated', 0), 'errors': result.get('errors', 0), + 'committed': bool(batch.confirmed_at), 'can_confirm': not batch.confirmed_at and batch.expires_at > timezone.now() + and (batch.payload['options']['errors'] == 'skip' or not result.get('errors')), + 'row_count': len(rows), 'row_page': page, 'rows': rows[(page-1)*100:page*100]} + + +class ProductImportPreviewApi(ManagementApi): + def post(self, request): + serializer = ImportOptionsSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + options = {key: data[key] for key in ('mode', 'create_categories', 'errors', 'ordering')} + batch = preview_batch(request.user, self.workspace, data['file'], options, data['sheet'], data['header_row'], data['mapping']) + return Response(batch_data(batch), status=201) + + +class ProductImportListApi(ManagementApi): + def get(self, request): + queryset = ProductImportBatch.objects.filter(sys_ws=self.workspace, created_by=request.user) + pagination = ManagementPagination() + rows = pagination.paginate_queryset(queryset, request) + results = [{key: value for key, value in batch_data(batch).items() if key != 'rows'} for batch in rows] + return pagination.get_paginated_response(results) + + +class ProductImportDetailApi(ManagementApi): + def get(self, request, pk): + batch = get_object_or_404(ProductImportBatch, pk=pk, sys_ws=self.workspace, created_by=request.user) + page_serializer = serializers.IntegerField(min_value=1, max_value=50) + page = page_serializer.run_validation(request.query_params.get('row_page', 1)) + return Response(batch_data(batch, page)) + + +class ProductImportConfirmApi(ManagementApi): + def post(self, request, pk): + return Response(batch_data(confirm_batch(pk, request.user, self.workspace))) + + +class ProductImportReportApi(ManagementApi): + def get(self, request, pk): + batch = get_object_or_404(ProductImportBatch, pk=pk, sys_ws=self.workspace, created_by=request.user) + return batch_report(batch) + + +class ProductImportTemplateApi(ManagementApi): + def get(self, request): + return import_template() diff --git a/apps/shop/views/order.py b/apps/shop/views/order.py index ab30b07..1c65ca8 100644 --- a/apps/shop/views/order.py +++ b/apps/shop/views/order.py @@ -11,6 +11,7 @@ from rest_framework import generics from apps.customer.models import Customer as CustomerModel from rest_framework.permissions import IsAuthenticated from utils import custom_permissions +import pandas as pd from utils.tools import inline_serializer from io import BytesIO @@ -212,13 +213,14 @@ class OrderRetrieveOperatorApi(APIView): class OrderRetrieveOperatorExcelApi(APIView): - permission_classes = (IsAuthenticated, ) + permission_classes = (IsAuthenticated, custom_permissions.WorkspaceOperatorPermission) def get(self, request, order_id): order_df = retrieve_order_item_excel(request=request, order_id=order_id) excel_file = BytesIO() - order_df.to_excel(excel_file, engine='xlsxwriter', index=False) + with pd.ExcelWriter(excel_file, engine='xlsxwriter', engine_kwargs={'options': {'strings_to_formulas': False, 'strings_to_urls': False}}) as writer: + order_df.to_excel(writer, index=False) excel_file.seek(0) response = HttpResponse(excel_file.read(), content_type='application/ms-excel') diff --git a/apps/shop/views/product.py b/apps/shop/views/product.py index 1b16ec5..5433109 100644 --- a/apps/shop/views/product.py +++ b/apps/shop/views/product.py @@ -16,11 +16,14 @@ class ProductCreateApi(APIView): permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission) class InputSerializer(serializers.Serializer): - name = serializers.CharField(required=True, max_length=20) - price = serializers.DecimalField(required=True, max_digits=20, decimal_places=2) + name = serializers.CharField(required=True, max_length=200) + price = serializers.IntegerField(required=True, min_value=0, max_value=9223372036854775807) category = serializers.IntegerField(required=True) acc_code = serializers.CharField(required=False) - attr = serializers.CharField(required=False) + attr = serializers.JSONField(required=False, allow_null=True) + order_prd = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647) + excel_order = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647) + view_only = serializers.BooleanField(required=False) class OutputSerializer(serializers.ModelSerializer): class Meta: @@ -64,11 +67,14 @@ class ProductUpdateApi(APIView): permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission) class InputSerializer(serializers.Serializer): - name = serializers.CharField(required=True, max_length=20) - price = serializers.DecimalField(required=True, max_digits=20, decimal_places=2) + name = serializers.CharField(required=True, max_length=200) + price = serializers.IntegerField(required=True, min_value=0, max_value=9223372036854775807) category = serializers.IntegerField(required=True) acc_code = serializers.CharField(required=False) - attr = serializers.CharField(required=False) + attr = serializers.JSONField(required=False, allow_null=True) + order_prd = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647) + excel_order = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647) + view_only = serializers.BooleanField(required=False) class OutputSerializer(serializers.ModelSerializer): class Meta: @@ -77,7 +83,7 @@ class ProductUpdateApi(APIView): def put(self, request, product_id: int): - serializer = self.InputSerializer(data=request.data) + serializer = self.InputSerializer(data=request.data, partial=True) serializer.is_valid(raise_exception=True) user_workspace_uuid = get_user_workspace_uuid(request) @@ -116,7 +122,7 @@ class ProductListApi(generics.ListAPIView): class OutputSerializer(serializers.ModelSerializer): class Meta: - model = ProductCategoryModel + model = ProductModel exclude = ('sys_ws', ) def get_queryset(self): @@ -154,7 +160,7 @@ class ProductListCategoryApi(generics.ListAPIView): ) data = { "category_name": category.name, - "category_img": category.image.url, + "category_img": category.image.url if category.image else None, "products": serializer.data, } diff --git a/apps/shop/views/product_category.py b/apps/shop/views/product_category.py index 0d4843b..bd91b70 100644 --- a/apps/shop/views/product_category.py +++ b/apps/shop/views/product_category.py @@ -18,8 +18,10 @@ class ProductCategoryCreateApi(APIView): permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission) class InputSerializer(serializers.Serializer): - name = serializers.CharField(required=True, max_length=20) - parent = serializers.IntegerField(required=False) + name = serializers.CharField(required=True, max_length=60) + parent = serializers.IntegerField(required=False, allow_null=True) + order_cat = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647) + excel_order = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647) class OutputSerializer(serializers.ModelSerializer): class Meta: @@ -62,7 +64,10 @@ class ProductCategoryUpdateApi(APIView): permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission) class InputSerializer(serializers.Serializer): - name = serializers.CharField(required=True) + name = serializers.CharField(required=False, max_length=60) + parent = serializers.IntegerField(required=False, allow_null=True) + order_cat = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647) + excel_order = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647) class OutputSerializer(serializers.ModelSerializer): class Meta: @@ -74,11 +79,11 @@ class ProductCategoryUpdateApi(APIView): serializer = self.InputSerializer(data=request.data) serializer.is_valid(raise_exception=True) - name = serializer.validated_data.get + name = serializer.validated_data.get("name") user_workspace_uuid = get_user_workspace_uuid(request) - product_category_obj = update_product_category(name=name, category_id=category_id, workspace_uuid=user_workspace_uuid) + product_category_obj = update_product_category(category_id=category_id, workspace_uuid=user_workspace_uuid, **serializer.validated_data) output = self.OutputSerializer(product_category_obj) @@ -146,6 +151,7 @@ class ProductCategoryListApi(generics.ListAPIView): class ProductsCategoryApi(APIView): + permission_classes = (IsAuthenticated, custom_permissions.WorkspaceCustomerPermission) class OutputSerializer(serializers.ModelSerializer): product_count = serializers.IntegerField() @@ -156,7 +162,9 @@ class ProductsCategoryApi(APIView): def get(self, request): - p_cate = ProductCategoryModel.objects.annotate(product_count=Count('product')).filter(product_count__gte=1).order_by('order_cat') + p_cate = ProductCategoryModel.objects.filter( + sys_ws__uuid=get_user_workspace_uuid(request), + ).annotate(product_count=Count('product')).filter(product_count__gte=1) output = self.OutputSerializer(p_cate, many=True) return Response(data=output.data, status=status.HTTP_200_OK) diff --git a/apps/ticket/admin.py b/apps/ticket/admin.py index 987afef..6dfa9f8 100644 --- a/apps/ticket/admin.py +++ b/apps/ticket/admin.py @@ -1,19 +1,32 @@ +from utils.admin_base import CRMModelAdmin from django.contrib import admin from apps.ticket.models import Ticket, TicketComment, TicketCommentFile # Register your models here. +class TicketCommentInline(admin.TabularInline): + model = TicketComment + extra = 0 + fields = ["desc","cuser_role","created_at"] + readonly_fields = fields + can_delete = False + show_change_link = True + + def has_add_permission(self, request, obj=None): + return False + + @admin.register(Ticket) -class TicketAdmin(admin.ModelAdmin): +class TicketAdmin(CRMModelAdmin): pass @admin.register(TicketComment) -class TicketCommentAdmin(admin.ModelAdmin): +class TicketCommentAdmin(CRMModelAdmin): pass @admin.register(TicketCommentFile) -class TicketCommentFileAdmin(admin.ModelAdmin): +class TicketCommentFileAdmin(CRMModelAdmin): pass diff --git a/apps/workspace/admin.py b/apps/workspace/admin.py index 205b30d..a18e3d8 100644 --- a/apps/workspace/admin.py +++ b/apps/workspace/admin.py @@ -1,3 +1,4 @@ +from utils.admin_base import CRMModelAdmin from django.contrib import admin from apps.workspace.models import ( Workspace as WorkspaceModel, @@ -6,7 +7,7 @@ from apps.workspace.models import ( # Register your models here. -class WorkspaceAdmin(admin.ModelAdmin): +class WorkspaceAdmin(CRMModelAdmin): pass @@ -14,6 +15,6 @@ admin.site.register(WorkspaceModel, WorkspaceAdmin) @admin.register(CityModel) -class CityAdmin(admin.ModelAdmin): +class CityAdmin(CRMModelAdmin): pass diff --git a/config/settings/base.py b/config/settings/base.py index c93cec6..2466d21 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -102,7 +102,7 @@ ROOT_URLCONF = 'config.urls' REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'apps.jwt_auth.authentication.SessionJWTAuthentication', ), } diff --git a/config/settings/test.py b/config/settings/test.py new file mode 100644 index 0000000..70579d4 --- /dev/null +++ b/config/settings/test.py @@ -0,0 +1,7 @@ +"""Isolated regression settings; production data and SMS are never used.""" +from .base import * + +DATABASES = {'default': {'ENGINE': 'config.test_sqlite', 'NAME': ':memory:'}} +SILENCED_SYSTEM_CHECKS = ['fields.E120'] # Existing PostgreSQL unbounded varchar. +MIDDLEWARE = [item for item in MIDDLEWARE if not item.startswith('whitenoise.')] +PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher'] diff --git a/config/test_sqlite/__init__.py b/config/test_sqlite/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/test_sqlite/base.py b/config/test_sqlite/base.py new file mode 100644 index 0000000..f49e34d --- /dev/null +++ b/config/test_sqlite/base.py @@ -0,0 +1,6 @@ +"""Mirror PostgreSQL's existing unbounded varchar fields in SQLite tests.""" +from django.db.backends.sqlite3.base import DatabaseWrapper as SQLiteDatabaseWrapper + + +class DatabaseWrapper(SQLiteDatabaseWrapper): + data_types = {**SQLiteDatabaseWrapper.data_types, 'CharField': 'varchar'} diff --git a/utils/admin_base.py b/utils/admin_base.py new file mode 100644 index 0000000..b598c40 --- /dev/null +++ b/utils/admin_base.py @@ -0,0 +1,109 @@ +"""Common admin ergonomics without changing application API contracts.""" +from django import forms +from django.contrib import admin +from django.db import models +from django.core.exceptions import ValidationError + +from utils.admin_excel import excel_response + + +def workspace_id(obj): + if obj is None: + return None + if hasattr(obj, 'sys_ws_id'): + return obj.sys_ws_id + for relation in ('order', 'form', 'ticket_comment'): + if hasattr(obj, relation): + return workspace_id(getattr(obj, relation)) + return None + + +class RelatedWorkspaceForm(forms.ModelForm): + def clean(self): + cleaned = super().clean() + workspace = cleaned.get('sys_ws') + scope = workspace.pk if workspace else None + if scope is None: + for key in ('order', 'form', 'ticket_comment'): + scope = workspace_id(cleaned.get(key)) + if scope is not None: + break + if scope is not None: + for field in self._meta.model._meta.fields: + if field.is_relation and field.name != 'sys_ws': + value = cleaned.get(field.name) + related_scope = workspace_id(value) + if related_scope is not None and related_scope != scope: + self.add_error(field.name, 'این رابطه متعلق به فضای کاری دیگری است.') + self.check_category_parent(cleaned) + self.check_domain_values(cleaned) + return cleaned + + def check_domain_values(self, cleaned): + label = self._meta.model._meta.label_lower + if label == 'cuser.cuserrole' and cleaned.get('mobile') is not None: + from utils.validations.mobile import normalize_mobile + try: + cleaned['mobile'] = normalize_mobile(cleaned['mobile']) + except ValidationError as error: + self.add_error('mobile', error.messages) + user = cleaned.get('cuser') + if user and user.mobile != cleaned.get('mobile'): + self.add_error('mobile', 'شماره نقش باید با شماره کاربر یکسان باشد.') + if label in ('shop.cart', 'shop.orderitem'): + key = 'quantity' if label == 'shop.cart' else 'amount' + if cleaned.get(key) is not None and cleaned[key] < 1: + self.add_error(key, 'تعداد باید حداقل یک باشد.') + if label == 'shop.orderitem' and cleaned.get('price') is not None and cleaned['price'] < 0: + self.add_error('price', 'قیمت نباید منفی باشد.') + + def check_category_parent(self, cleaned): + if self._meta.model._meta.label_lower != 'shop.productcategory': + return + parent = cleaned.get('parent') + visited = set() + while parent is not None: + if parent.pk == self.instance.pk or parent.pk in visited: + self.add_error('parent', 'دسته والد نباید یک چرخه ایجاد کند.') + return + visited.add(parent.pk) + parent = parent.parent + + +class CRMModelAdmin(admin.ModelAdmin): + form = RelatedWorkspaceForm + list_per_page = 50 + show_full_result_count = False + save_on_top = True + actions = ('export_selected',) + sensitive_fields = () + + def __init__(self, model, admin_site): + super().__init__(model, admin_site) + fields = model._meta.fields + names = {field.name for field in fields} + if self.list_display == ('__str__',): + candidates = ('id', 'name', 'name_family', 'title', 'mobile', 'customer', 'lead', 'form', + 'product', 'order', 'cuser_role', 'role_type', 'sys_ws', 'status', 'type', + 'is_active', 'expire', 'datetime', 'created_at') + self.list_display = tuple(name for name in candidates if name in names)[:9] + if not self.search_fields: + self.search_fields = tuple(field.name for field in fields if isinstance(field, (models.CharField, models.TextField)) + and field.name not in self.sensitive_fields) + if not self.list_filter: + self.list_filter = tuple(field.name for field in fields if field.name == 'sys_ws' or field.choices + or isinstance(field, models.BooleanField)) + self.readonly_fields = tuple(dict.fromkeys((*self.readonly_fields, + *(name for name in ('created_at', 'updated_at', 'uuid') if name in names)))) + self.raw_id_fields = tuple(field.name for field in fields if field.many_to_one) + self.list_select_related = tuple(field.name for field in fields if field.many_to_one) + if 'created_at' in names: + self.date_hierarchy = 'created_at' + if not self.ordering and not model._meta.ordering: + self.ordering = ('-pk',) + + @admin.action(description='خروجی اکسل ردیف‌های انتخاب‌شده', permissions=['view']) + def export_selected(self, request, queryset): + fields = [field for field in self.model._meta.fields if field.name not in self.sensitive_fields] + rows = queryset.values_list(*(field.attname for field in fields)).iterator() + return excel_response([str(field.verbose_name) for field in fields], rows, f'{self.model._meta.model_name}.xlsx') diff --git a/utils/admin_excel.py b/utils/admin_excel.py new file mode 100644 index 0000000..c04888f --- /dev/null +++ b/utils/admin_excel.py @@ -0,0 +1,39 @@ +from datetime import datetime +from io import BytesIO +from itertools import chain +import json + +from django.http import HttpResponse +from openpyxl import Workbook +from openpyxl.cell import WriteOnlyCell +from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE +from openpyxl.utils import get_column_letter + + +def excel_response(headers, rows, filename): + workbook = Workbook(write_only=True) + sheet = workbook.create_sheet('Products') + sheet.sheet_view.rightToLeft = True + sheet.freeze_panes = 'A2' + for index in range(1, len(headers) + 1): + sheet.column_dimensions[get_column_letter(index)].width = 24 + for number, row in enumerate(chain((headers,), rows), 1): + cells = [] + for value in row: + if isinstance(value, datetime) and value.tzinfo: + value = value.isoformat() + if isinstance(value, (dict, list)): + value = json.dumps(value, ensure_ascii=False) + if isinstance(value, str): + value = ILLEGAL_CHARACTERS_RE.sub('', value) + cell = WriteOnlyCell(sheet, value=value) + if isinstance(value, str): + cell.data_type = 's' + cells.append(cell) + sheet.append(cells) + sheet.auto_filter.ref = f'A1:{get_column_letter(len(headers))}{number}' + content = BytesIO() + workbook.save(content) + response = HttpResponse(content.getvalue(), content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response diff --git a/utils/services/sms.py b/utils/services/sms.py index dad2a58..291202e 100644 --- a/utils/services/sms.py +++ b/utils/services/sms.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod import requests +from django.conf import settings class SmsProviderABC(ABC): @@ -23,10 +24,12 @@ class DosefrNavadHashtSMS(SmsProviderABC): 'TO': int(mobile), 'TEXT': f'{message}' } - r = requests.get('http://www.0098sms.com/sendsmslink.aspx', params=payload) - - if r.status_code == 200: - return True - else: + try: + r = requests.get('http://www.0098sms.com/sendsmslink.aspx', params=payload, timeout=10) + r.raise_for_status() + except requests.RequestException: return False + # An HTTP 200 alone does not mean the SMS provider accepted the message. + success_codes = getattr(settings, 'SMS_SUCCESS_CODES', ('0',)) + return r.text.strip() in success_codes diff --git a/utils/validations/mobile.py b/utils/validations/mobile.py new file mode 100644 index 0000000..e87bdc7 --- /dev/null +++ b/utils/validations/mobile.py @@ -0,0 +1,14 @@ +from django.core.exceptions import ValidationError + + +def normalize_mobile(value): + """Keep the integer storage format while accepting Iranian mobile formats.""" + mobile = str(value).strip().translate(str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")) + mobile = mobile.replace(" ", "").replace("-", "") + for prefix in ("+98", "0098", "98", "0"): + if mobile.startswith(prefix) and len(mobile) == 10 + len(prefix): + mobile = mobile[len(prefix):] + break + if len(mobile) != 10 or not mobile.isascii() or not mobile.isdigit() or not mobile.startswith("9"): + raise ValidationError({"mobile": "شماره موبایل معتبر وارد کنید."}) + return int(mobile)