aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomas "Cakeisalie5" Touhey <thomas@touhey.fr>2018-10-02 22:20:11 +0200
committerThomas "Cakeisalie5" Touhey <thomas@touhey.fr>2018-10-02 22:20:11 +0200
commit5d450d4f8ca23c00e2e0beb0287c7221dd8a207e (patch)
tree72d0c11b5c57c6301c77819bfd6e4ed7cb7598ba
Initial commit
-rw-r--r--.gitignore8
-rw-r--r--LICENSE.txt21
-rw-r--r--Pipfile14
-rw-r--r--Pipfile.lock212
-rw-r--r--README.md1
-rw-r--r--setup.cfg32
-rwxr-xr-xsetup.py14
-rwxr-xr-xsgdfi/__init__.py13
-rwxr-xr-xsgdfi/__main__.py67
-rwxr-xr-xsgdfi/_manager.py548
-rwxr-xr-xsgdfi/_repr.py150
-rwxr-xr-xsgdfi/_session.py315
-rwxr-xr-xsgdfi/_util.py401
-rw-r--r--sgdfi/_version.py13
-rwxr-xr-xtest/__init__.py11
15 files changed, 1820 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f63ab80
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+__pycache__
+/test.py
+/*.egg-info
+/dist
+/.spyproject
+/build
+/docs/_build
+/venv
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..45ccebe
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (C) 2018 Thomas Touhey <thomas@touhey.fr>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the “Software”), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+IN THE SOFTWARE.
diff --git a/Pipfile b/Pipfile
new file mode 100644
index 0000000..7ab2ac6
--- /dev/null
+++ b/Pipfile
@@ -0,0 +1,14 @@
+[[source]]
+url = "https://pypi.python.org/simple"
+verify_ssl = true
+
+[packages]
+requests = "*"
+regex = "*"
+bs4 = "*"
+pandas = "*"
+lxml = "*"
+appdirs = "*"
+
+[requires]
+python_version = "3.7"
diff --git a/Pipfile.lock b/Pipfile.lock
new file mode 100644
index 0000000..0076810
--- /dev/null
+++ b/Pipfile.lock
@@ -0,0 +1,212 @@
+{
+ "_meta": {
+ "hash": {
+ "sha256": "941a49eab3b826abd89e2d23527785f4e336ca37648242732729cfd3d571cc9c"
+ },
+ "pipfile-spec": 6,
+ "requires": {
+ "python_version": "3.7"
+ },
+ "sources": [
+ {
+ "url": "https://pypi.python.org/simple",
+ "verify_ssl": true
+ }
+ ]
+ },
+ "default": {
+ "appdirs": {
+ "hashes": [
+ "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
+ "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
+ ],
+ "version": "==1.4.3"
+ },
+ "beautifulsoup4": {
+ "hashes": [
+ "sha256:194ec62a25438adcb3fdb06378b26559eda1ea8a747367d34c33cef9c7f48d57",
+ "sha256:90f8e61121d6ae58362ce3bed8cd997efb00c914eae0ff3d363c32f9a9822d10",
+ "sha256:f0abd31228055d698bb392a826528ea08ebb9959e6bea17c606fd9c9009db938"
+ ],
+ "version": "==4.6.3"
+ },
+ "bs4": {
+ "hashes": [
+ "sha256:36ecea1fd7cc5c0c6e4a1ff075df26d50da647b75376626cc186e2212886dd3a"
+ ],
+ "version": "==0.0.1"
+ },
+ "certifi": {
+ "hashes": [
+ "sha256:376690d6f16d32f9d1fe8932551d80b23e9d393a8578c5633a2ed39a64861638",
+ "sha256:456048c7e371c089d0a77a5212fb37a2c2dce1e24146e3b7e0261736aaeaa22a"
+ ],
+ "version": "==2018.8.24"
+ },
+ "chardet": {
+ "hashes": [
+ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
+ "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+ ],
+ "version": "==3.0.4"
+ },
+ "idna": {
+ "hashes": [
+ "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
+ "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"
+ ],
+ "version": "==2.7"
+ },
+ "lxml": {
+ "hashes": [
+ "sha256:02bc220d61f46e9b9d5a53c361ef95e9f5e1d27171cd461dddb17677ae2289a5",
+ "sha256:22f253b542a342755f6cfc047fe4d3a296515cf9b542bc6e261af45a80b8caf6",
+ "sha256:2f31145c7ff665b330919bfa44aacd3a0211a76ca7e7b441039d2a0b0451e415",
+ "sha256:36720698c29e7a9626a0dc802ef8885f8f0239bfd1689628ecd459a061f2807f",
+ "sha256:438a1b0203545521f6616132bfe0f4bca86f8a401364008b30e2b26ec408ce85",
+ "sha256:4815892904c336bbaf73dafd54f45f69f4021c22b5bad7332176bbf4fb830568",
+ "sha256:5be031b0f15ad63910d8e5038b489d95a79929513b3634ad4babf77100602588",
+ "sha256:5c93ae37c3c588e829b037fdfbd64a6e40c901d3f93f7beed6d724c44829a3ad",
+ "sha256:60842230678674cdac4a1cf0f707ef12d75b9a4fc4a565add4f710b5fcf185d5",
+ "sha256:62939a8bb6758d1bf923aa1c13f0bcfa9bf5b2fc0f5fa917a6e25db5fe0cfa4e",
+ "sha256:75830c06a62fe7b8fe3bbb5f269f0b308f19f3949ac81cfd40062f47c1455faf",
+ "sha256:81992565b74332c7c1aff6a913a3e906771aa81c9d0c68c68113cffcae45bc53",
+ "sha256:8c892fb0ee52c594d9a7751c7d7356056a9682674b92cc1c4dc968ff0f30c52f",
+ "sha256:9d862e3cf4fc1f2837dedce9c42269c8c76d027e49820a548ac89fdcee1e361f",
+ "sha256:a623965c086a6e91bb703d4da62dabe59fe88888e82c4117d544e11fd74835d6",
+ "sha256:a7783ab7f6a508b0510490cef9f857b763d796ba7476d9703f89722928d1e113",
+ "sha256:aab09fbe8abfa3b9ce62aaf45aca2d28726b1b9ee44871dbe644050a2fff4940",
+ "sha256:abf181934ac3ef193832fb973fd7f6149b5c531903c2ec0f1220941d73eee601",
+ "sha256:ae07fa0c115733fce1e9da96a3ac3fa24801742ca17e917e0c79d63a01eeb843",
+ "sha256:b9c78242219f674ab645ec571c9a95d70f381319a23911941cd2358a8e0521cf",
+ "sha256:bccb267678b870d9782c3b44d0cefe3ba0e329f9af8c946d32bf3778e7a4f271",
+ "sha256:c4df4d27f4c93b2cef74579f00b1d3a31a929c7d8023f870c4b476f03a274db4",
+ "sha256:caf0e50b546bb60dfa99bb18dfa6748458a83131ecdceaf5c071d74907e7e78a",
+ "sha256:d3266bd3ac59ac4edcd5fa75165dee80b94a3e5c91049df5f7c057ccf097551c",
+ "sha256:db0d213987bcd4e6d41710fb4532b22315b0d8fb439ff901782234456556aed1",
+ "sha256:dbbd5cf7690a40a9f0a9325ab480d0fccf46d16b378eefc08e195d84299bfae1",
+ "sha256:e16e07a0ec3a75b5ee61f2b1003c35696738f937dc8148fbda9fe2147ccb6e61",
+ "sha256:e175a006725c7faadbe69e791877d09936c0ef2cf49d01b60a6c1efcb0e8be6f",
+ "sha256:edd9c13a97f6550f9da2236126bb51c092b3b1ce6187f2bd966533ad794bbb5e",
+ "sha256:fa39ea60d527fbdd94215b5e5552f1c6a912624521093f1384a491a8ad89ad8b"
+ ],
+ "version": "==4.2.5"
+ },
+ "numpy": {
+ "hashes": [
+ "sha256:1b1cf8f7300cf7b11ddb4250b3898c711a6187df05341b5b7153db23ffe5d498",
+ "sha256:27a0d018f608a3fe34ac5e2b876f4c23c47e38295c47dd0775cc294cd2614bc1",
+ "sha256:3fde172e28c899580d32dc21cb6d4a1225d62362f61050b654545c662eac215a",
+ "sha256:497d7c86df4f85eb03b7f58a7dd0f8b948b1f582e77629341f624ba301b4d204",
+ "sha256:4e28e66cf80c09a628ae680efeb0aa9a066eb4bb7db2a5669024c5b034891576",
+ "sha256:58be95faf0ca2d886b5b337e7cba2923e3ad1224b806a91223ea39f1e0c77d03",
+ "sha256:5b4dfb6551eaeaf532054e2c6ef4b19c449c2e3a709ebdde6392acb1372ecabc",
+ "sha256:63f833a7c622e9082df3cbaf03b4fd92d7e0c11e2f9d87cb57dbf0e84441964b",
+ "sha256:71bf3b7ca15b1967bba3a1ef6a8e87286382a8b5e46ac76b42a02fe787c5237d",
+ "sha256:733dc5d47e71236263837825b69c975bc08728ae638452b34aeb1d6fa347b780",
+ "sha256:82f00a1e2695a0e5b89879aa25ea614530b8ebdca6d49d4834843d498e8a5e92",
+ "sha256:866bf72b9c3bfabe4476d866c70ee1714ad3e2f7b7048bb934892335e7b6b1f7",
+ "sha256:8aeac8b08f4b8c52129518efcd93706bb6d506ccd17830b67d18d0227cf32d9e",
+ "sha256:8d2cfb0aef7ec8759736cce26946efa084cdf49797712333539ef7d135e0295e",
+ "sha256:981224224bbf44d95278eb37996162e8beb6f144d2719b144e86dfe2fce6c510",
+ "sha256:981daff58fa3985a26daa4faa2b726c4e7a1d45178100125c0e1fdaf2ac64978",
+ "sha256:9ad36dbfdbb0cba90a08e7343fadf86f43cf6d87450e8d2b5d71d7c7202907e4",
+ "sha256:a251570bb3cb04f1627f23c234ad09af0e54fc8194e026cf46178f2e5748d647",
+ "sha256:b5ff7dae352fd9e1edddad1348698e9fea14064460a7e39121ef9526745802e6",
+ "sha256:c898f9cca806102fcacb6309899743aa39efb2ad2a302f4c319f54db9f05cd84",
+ "sha256:cf4b970042ce148ad8dce4369c02a4078b382dadf20067ce2629c239d76460d1",
+ "sha256:d1569013e8cc8f37e9769d19effdd85e404c976cd0ca28a94e3ddc026c216ae8",
+ "sha256:dca261e85fe0d34b2c242ecb31c9ab693509af2cf955d9caf01ee3ef3669abd0",
+ "sha256:ec8bf53ef7c92c99340972519adbe122e82c81d5b87cbd955c74ba8a8cd2a4ad",
+ "sha256:f2e55726a9ee2e8129d6ce6abb466304868051bcc7a09d652b3b07cd86e801a2",
+ "sha256:f4dee74f2626c783a3804df9191e9008946a104d5a284e52427a53ff576423cb",
+ "sha256:f592fd7fe1f20b5041928cce1330937eca62f9058cb41e69c2c2d83cffc0d1e3",
+ "sha256:ffab5b80bba8c86251291b8ce2e6c99a61446459d4c6637f5d5cc8c9ce37c972"
+ ],
+ "markers": "python_version != '3.1.*' and python_version != '3.0.*' and python_version >= '2.7' and python_version != '3.2.*' and python_version != '3.3.*'",
+ "version": "==1.15.2"
+ },
+ "pandas": {
+ "hashes": [
+ "sha256:11975fad9edbdb55f1a560d96f91830e83e29bed6ad5ebf506abda09818eaf60",
+ "sha256:12e13d127ca1b585dd6f6840d3fe3fa6e46c36a6afe2dbc5cb0b57032c902e31",
+ "sha256:1c87fcb201e1e06f66e23a61a5fea9eeebfe7204a66d99df24600e3f05168051",
+ "sha256:242e9900de758e137304ad4b5663c2eff0d798c2c3b891250bd0bd97144579da",
+ "sha256:26c903d0ae1542890cb9abadb4adcb18f356b14c2df46e4ff657ae640e3ac9e7",
+ "sha256:2e1e88f9d3e5f107b65b59cd29f141995597b035d17cc5537e58142038942e1a",
+ "sha256:31b7a48b344c14691a8e92765d4023f88902ba3e96e2e4d0364d3453cdfd50db",
+ "sha256:4fd07a932b4352f8a8973761ab4e84f965bf81cc750fb38e04f01088ab901cb8",
+ "sha256:5b24ca47acf69222e82530e89111dd9d14f9b970ab2cd3a1c2c78f0c4fbba4f4",
+ "sha256:647b3b916cc8f6aeba240c8171be3ab799c3c1b2ea179a3be0bd2712c4237553",
+ "sha256:66b060946046ca27c0e03e9bec9bba3e0b918bafff84c425ca2cc2e157ce121e",
+ "sha256:6efa9fa6e1434141df8872d0fa4226fc301b17aacf37429193f9d70b426ea28f",
+ "sha256:be4715c9d8367e51dbe6bc6d05e205b1ae234f0dc5465931014aa1c4af44c1ba",
+ "sha256:bea90da782d8e945fccfc958585210d23de374fa9294a9481ed2abcef637ebfc",
+ "sha256:d318d77ab96f66a59e792a481e2701fba879e1a453aefeebdb17444fe204d1ed",
+ "sha256:d785fc08d6f4207437e900ffead930a61e634c5e4f980ba6d3dc03c9581748c7",
+ "sha256:de9559287c4fe8da56e8c3878d2374abc19d1ba2b807bfa7553e912a8e5ba87c",
+ "sha256:f4f98b190bb918ac0bc0e3dd2ab74ff3573da9f43106f6dba6385406912ec00f",
+ "sha256:f71f1a7e2d03758f6e957896ed696254e2bc83110ddbc6942018f1a232dd9dad",
+ "sha256:fb944c8f0b0ab5c1f7846c686bc4cdf8cde7224655c12edcd59d5212cd57bec0"
+ ],
+ "version": "==0.23.4"
+ },
+ "python-dateutil": {
+ "hashes": [
+ "sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0",
+ "sha256:e27001de32f627c22380a688bcc43ce83504a7bc5da472209b4c70f02829f0b8"
+ ],
+ "version": "==2.7.3"
+ },
+ "pytz": {
+ "hashes": [
+ "sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053",
+ "sha256:ffb9ef1de172603304d9d2819af6f5ece76f2e85ec10692a524dd876e72bf277"
+ ],
+ "version": "==2018.5"
+ },
+ "regex": {
+ "hashes": [
+ "sha256:22d7ef8c2df344328a8a3c61edade2ee714e5de9360911d22a9213931c769faa",
+ "sha256:3a699780c6b712c67dc23207b129ccc6a7e1270233f7aadead3ea3f83c893702",
+ "sha256:42f460d349baebd5faec02a0c920988fb0300b24baf898d9c139886565b66b6c",
+ "sha256:43bf3d79940cbdf19adda838d8b26b28b47bec793cda46590b5b25703742f440",
+ "sha256:47d6c7f0588ef33464e00023067c4e7cce68e0d6a686a73c7ee15abfdad503d4",
+ "sha256:5b879f59f25ed9b91bc8693a9a994014b431f224f492519ad0255ce6b54b83e5",
+ "sha256:8ba0093c412900f636b0f826c597a0c3ea0e395344bc99894ddefe88b76c9c7e",
+ "sha256:a4789254a1a0bd7a637036cce0b7ed72d8cc864e93f2e9cfd10ac00ae27bb7b0",
+ "sha256:b73cea07117dca888b0c3671770b501bef19aac9c45c8ffdb5bea2cca2377b0a",
+ "sha256:d3eb59fa3e5b5438438ec97acd9dc86f077428e020b015b43987e35bea68ef4c",
+ "sha256:d51d232b4e2f106deaf286001f563947fee255bc5bd209a696f027e15cf0a1e7",
+ "sha256:d59b03131a8e35061b47a8f186324a95eaf30d5f6ee9cc0637e7b87d29c7c9b5",
+ "sha256:dd705df1b47470388fc4630e4df3cbbe7677e2ab80092a1c660cae630a307b2d",
+ "sha256:e87fffa437a4b00afb17af785da9b01618425d6cd984c677639deb937037d8f2",
+ "sha256:ed40e0474ab5ab228a8d133759d451b31d3ccdebaff698646e54aff82c3de4f8"
+ ],
+ "version": "==2018.8.29"
+ },
+ "requests": {
+ "hashes": [
+ "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
+ "sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
+ ],
+ "version": "==2.19.1"
+ },
+ "six": {
+ "hashes": [
+ "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
+ "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
+ ],
+ "version": "==1.11.0"
+ },
+ "urllib3": {
+ "hashes": [
+ "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
+ "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
+ ],
+ "markers": "python_version != '3.1.*' and python_version != '3.3.*' and python_version >= '2.6' and python_version < '4' and python_version != '3.2.*' and python_version != '3.0.*'",
+ "version": "==1.23"
+ }
+ },
+ "develop": {}
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..98ff6d5
--- /dev/null
+++ b/README.md
@@ -0,0 +1 @@
+# SGDFi : interagir avec l'intranet SGDF
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..2ee80e6
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,32 @@
+[metadata]
+name = sgdfi
+version = attr: sgdfi.version
+url = https://forge.touhey.fr/sgdf/sgdfi.git
+author = Thomas Touhey
+author_email = thomas@touhey.fr
+description = interactions with SGDF's intranet
+long_description = file: README.rst
+keywords = sgdf, intranet.sgdf.fr
+license = MIT
+classifiers =
+ Development Status :: 2 - Pre-Alpha
+ License :: OSI Approved :: MIT License
+ Natural Language :: French
+ Operating System :: OS Independent
+ Programming Language :: Python :: 3
+
+[options]
+zip_safe = False
+include_package_data = True
+packages = sgdfi
+test_suite = test
+
+[options.package_data]
+* = *.txt, *.rst
+
+[wheel]
+universal = True
+
+[flake8]
+ignore = F401, F403, E128, E131, E241, E261, E265, E271, W191
+exclude = .git, __pycache__, build, dist, test.py, test
diff --git a/setup.py b/setup.py
new file mode 100755
index 0000000..2f37b30
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python3
+#******************************************************************************
+# Copyright (C) 2018 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr>
+# This file is part of the fingerd Python 3.x module, which is MIT-licensed.
+#******************************************************************************
+""" Setup script for the textoutpc Python package and script. """
+
+from setuptools import setup as _setup
+
+# Actually, most of the project's data is read from the `setup.cfg` file.
+
+_setup()
+
+# End of file.
diff --git a/sgdfi/__init__.py b/sgdfi/__init__.py
new file mode 100755
index 0000000..aa40b62
--- /dev/null
+++ b/sgdfi/__init__.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+#******************************************************************************
+# Copyright (C) 2018 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr>
+# This file is part of the sgdfi project, which is MIT-licensed.
+#******************************************************************************
+""" SGDFi allows you to interact with SGDF's intranet. """
+
+from ._session import Session
+from ._version import version
+
+__all__ = ["version", "Session"]
+
+# End of file.
diff --git a/sgdfi/__main__.py b/sgdfi/__main__.py
new file mode 100755
index 0000000..2b78129
--- /dev/null
+++ b/sgdfi/__main__.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+#******************************************************************************
+# Copyright (C) 2018 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr>
+# This file is part of the sgdfi project, which is MIT-licensed.
+#******************************************************************************
+""" Main script for testing things with SGDFi. """
+
+import os.path as _path
+
+def test_session():
+ """ Test the session. """
+
+ from . import Session as _Session
+
+ path = _path.join(_path.dirname(__file__), '..', '..', 'logins.txt')
+ user, pw = (x.splitlines()[0] for x in open(path).readlines())
+
+ s = _Session(user = user, pw = pw, save = True)
+
+ ops = s.get_ops('4', "yCbyTmNDHpp8CotDhWoEkQ==")
+ for op in ops:
+ print(op)
+
+def test_repr():
+ """ Test the representations. """
+
+ from datetime import datetime as _datetime
+ from ._repr import Operation as _Operation, Adherent as _Adherent
+
+ op = _Operation()
+ op.time = _datetime(2018, 10, 1, 0, 17, 38)
+ op.typename = "Individu / Abonnement "
+ op.author = "LEFEBVRE CAROLE"
+ op.fields = "Revue: Revue Louveteau-Jeannette, Type: Gratuit, " \
+ "Fin: 31/08/2019, Prix: 0,00€ "
+
+ ad = _Adherent()
+ ad.iid = "yCbyTmNDHpp8CotDhWoEkQ=="
+ ad.name = "TOUHEY Thomas"
+
+ op.related.add(ad)
+ print(op)
+
+def test_save():
+ """ Test the saving. """
+
+ from ._manager import Manager
+
+ man = Manager(save = True)
+ man.save("hello world", ext = "html")
+
+def test_load():
+ """ Test the loading. """
+
+ from ._manager import Manager
+
+ man = Manager()
+ ret = man.load("2018100222113100-operations.html")
+ print(ret)
+
+if __name__ == '__main__':
+ #test_repr()
+ #test_session()
+ #test_save()
+ test_load()
+
+# End of file.
diff --git a/sgdfi/_manager.py b/sgdfi/_manager.py
new file mode 100755
index 0000000..616f31a
--- /dev/null
+++ b/sgdfi/_manager.py
@@ -0,0 +1,548 @@
+#!/usr/bin/env python3
+#******************************************************************************
+# Copyright (C) 2018 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr>
+# This file is part of the sgdfi project, which is MIT-licensed.
+#******************************************************************************
+""" Definition of the main object to decode and manage objects of the
+ SGDF's intranet, without the network part which is managed by the
+ `Session` object, which inherits from this class to manage its
+ received data. """
+
+import os.path as _path
+
+from os import makedirs as _makedirs, open as _open, fdopen as _fdopen, \
+ O_WRONLY as _O_WRONLY, O_CREAT as _O_CREAT, O_EXCL as _O_EXCL
+from itertools import count as _count
+from datetime import datetime as _datetime
+from re import finditer as _rfindter, sub as _rreplace, split as _rsplit
+from io import IOBase as _IOBase
+from base64 import b64decode as _b64decode
+from urllib.parse import urlparse as _urlparse, parse_qs as _parse_qs, \
+ unquote as _unquote
+from html import unescape as _htmlunescape
+from csv import reader as _csvreader
+
+from bs4 import BeautifulSoup as _BeautifulSoup
+from pandas import read_excel as _read_excel
+from appdirs import user_cache_dir as _user_cache_dir
+
+from ._repr import Structure as _Structure, Adherent as _Adherent, \
+ Place as _Place, RallyRegistration as _RallyRegistration, Camp as _Camp, \
+ Operation as _Operation
+
+__all__ = ["Manager"]
+
+# Internal class.
+
+class _Pagination:
+ def __init__(self):
+ self.current = 0
+ self.number = 0
+ self.more = False
+
+# The main class.
+
+class Manager:
+ """ Manage objects from SGDF's intranet. """
+
+ def __init__(self, save = False, folder = None):
+ self._pgn = _Pagination()
+ self.__save = save
+ self.__folder = folder
+
+ # Work out the folder, and make sure it exists.
+
+ if folder is None:
+ self.__folder = _user_cache_dir(appname = "sgdfi")
+ try:
+ _makedirs(self.__folder)
+ except FileExistsError:
+ pass
+
+ # ---
+ # Save file management.
+ # ---
+
+ def load(self, name):
+ """ Read from a saved dump. """
+
+ path = _path.join(self.__folder, name)
+
+ name, *ext = name.split('.')
+ ext = '.'.join(ext)
+ name, *hint = name.split('-')
+ hint = '-'.join(hint)
+ if not hint:
+ hint = None
+
+ if ext == 'html':
+ type = 'html'
+ elif ext == 'txt':
+ type = 'ajax'
+ elif ext == 'csv':
+ type = 'csv'
+ elif ext == 'xls':
+ type = 'xls'
+ else:
+ type = None
+
+ return self.read(path, type, hint)
+
+ def save(self, content, hint = None, ext = 'txt'):
+ """ Save a file. """
+
+ if not self.__save:
+ return
+
+ fdmode = 'w'
+ if type(hint) == str:
+ hint = f"-{hint}"
+ else:
+ hint = ""
+
+ srt = f"{_datetime.now().strftime('%Y%m%d%H%M%S')}"
+ end = f"{hint}.{ext}"
+
+ for idx in range(100):
+ try:
+ fd = _open(_path.join(self.__folder, f"{srt}{idx:02d}{end}"),
+ _O_WRONLY | _O_CREAT | _O_EXCL)
+ except FileExistsError:
+ continue
+
+ f = _fdopen(fd, fdmode)
+ break
+ else:
+ raise ValueError("Could not find a suitable index…")
+
+ f.write(content)
+
+ # ---
+ # Decoding/feeding part.
+ # ---
+
+ def read(self, path, type, hint = None):
+ """ Read from any file. """
+
+ return self.feed(open(path).read(), type, hint)
+
+ def feed(self, content, type, hint = None):
+ """ Feed the manager with a document.
+
+ `content`: the raw content as UTF-8 encoded text.
+ `type`: the type amongst "html", "ajax", "csv", "xls".
+ `hint`: the hint. """
+
+ if hint == 'ignore':
+ return None
+
+ if type == 'html':
+ self.save(content, hint, 'html')
+
+ if hint == 'operations':
+ func = self.__feed_html_operations
+ elif hint == 'lieu':
+ func = self.__feed_html_lieu
+ elif hint == 'personlist_fragment':
+ func = self.__feed_html_personlist_fragment
+ elif hint == 'person_summary_fragment':
+ func = self.__feed_html_person_summary_fragment
+ elif hint == 'person_family_fragment':
+ func = self.__feed_html_person_family_fragment
+ elif hint == 'structure_summary':
+ func = self.__feed_html_structure_summary
+ elif hint == 'structure_hierarchy':
+ func = self.__feed_html_structure_hierarchy
+ elif hint == 'calendar_month_fragment':
+ func = self.__feed_html_calendar_month_fragment
+ else:
+ raise ValueError(f"unknown html hint: {repr(hint)}")
+
+ content = _BeautifulSoup(content, 'lxml')
+ return func(content)
+ elif type == 'ajax':
+ self.save(content, hint, 'txt')
+
+ if hint == 'personlist':
+ func = self.__feed_html_personlist_fragment
+ elif hint == 'person_summary':
+ func = self.__feed_html_person_summary_fragment
+ elif hint == 'person_family':
+ func = self.__feed_html_person_family_fragment
+ elif hint == 'calendar_month':
+ func = self.__feed_html_calendar_month_fragment
+ elif hint is None:
+ func = None
+ else:
+ raise ValueError(f"unknown ajax hint: {repr(hint)}")
+
+ # Les réponses aux appels AJAX sont un ensemble d'éléments séparés
+ # par des pipes ('|'), à prendre par groupe de quatre tels que
+ # décrits par le document `intranet.rst`.
+
+ resp = []
+ raw = iter(text.split('|')[:-1])
+ for code in raw:
+ code = int(code)
+ name = next(raw)
+ attrib = next(raw)
+ text = next(raw)
+
+ resp.append(_Field(code, name, attrib, text))
+
+ # FIXME: vérifier s'il y a eu une erreur.
+
+ # La charge utile de la réponse est dans la seconde entrée
+ # généralement. Le code permet de déterminer de quoi il en
+ # retourne, mais beaucoup de codes peuvent être émis selon les
+ # actions précédentes, donc on fait confiance au contexte.
+
+ # TODO: si `hint` est égal à None ici, par rapport aux codes
+ # connus, on peut tenter d'identifier les fragments ?
+ # e.g. `if field.code in (25XXX, 25XXX, …): func = …`.
+
+ field = resp[1]
+ if hint is None:
+ raise ValueError("required ajax hint")
+
+ return func(field.text)
+ elif type == 'csv':
+ self.save(content, hint, 'csv')
+
+ if hint == 'attend':
+ func = self.__feed_csv_attend
+ else:
+ raise ValueError(f"unknown csv hint: {repr(hint)}")
+
+ if not isinstance(content, _IOBase):
+ stream = _StringIO(content)
+ reader = _csvreader(content, delimiter = ';')
+ resp = [row for row in reader]
+
+ return func(resp)
+ elif type == 'xls':
+ self.save(content, hint, 'xls')
+
+ if hint == 'people':
+ func = self.__feed_xls_people
+ else:
+ raise ValueError(f"unknown xls hint: {repr(hint)}")
+
+ def entries(content):
+ """ Récupération des entrées depuis une dataframe, et
+ extraction en tant qu'itérateur. """
+
+ df = _read_excel(stream)
+
+ for i in _it.count():
+ try:
+ yield {i.replace(".", ""): j \
+ for i, j in dict(df.ix[i]).items()}
+ except KeyError:
+ break
+
+ resp = [e for e in entries(content)]
+ return func(resp)
+ else:
+ self.save(content, hint, 'bin')
+
+ raise ValueError(f"unknown type: {repr(type)}")
+
+ # ---
+ # HTML pages and fragments decoding.
+ # ---
+
+ def __feed_html_operations(self, content):
+ """ Decode the HTML operations from a BeautifulSoup decoded
+ content and feed it into the manager's operations. """
+
+ stprefix = '/Specialisation/Sgdf/structures/ResumeStructure.aspx'
+ adprefix = '/Specialisation/Sgdf/adherents/ResumeAdherent.aspx'
+ irprefix = '/Specialisation/Sgdf/Rassemblements/' \
+ 'InscriptionRassemblementV2.aspx'
+ cpprefix = '/Specialisation/Sgdf/camps/ConsulterModifierCamp.aspx'
+ laprefix = '/Specialisation/Sgdf/Commun/ResumeLieuActivite.aspx'
+
+ parent = content.find(id = 'ctl00_Popup__evenements__gvEvenements')
+ if not parent:
+ return [_Pagination(0, False, 1)]
+
+ elts = []
+
+ # Récupération de la pagination.
+ # `numpages` : numéro maximal de page dans la pagination.
+ # `more`: y a-t-il plus de pages (la dernière page est-elle
+ # en « ... » ?).
+ # `curpage` : page actuelle selon la pagination.
+
+ p = parent.find('tr', {'class': ['pagination']})
+ if p != None:
+ p = p.find('tr')
+ td = p.find_all('td')[-1]
+ button = next(td.children)
+
+ if button.name == 'span':
+ num = button.text.strip()
+ more = False
+ else:
+ num = button['href']
+ num = num[num.find("'Page$") + 6:]
+ num = num[:num.find("'")]
+ more = button.text == '...'
+
+ numpages = int(num)
+
+ for td in p.find_all('td'):
+ child = next(td.children)
+ if child.name == 'span':
+ curpage = int(child.text)
+ break
+ else:
+ curpage = 1
+ numpages = 1
+ more = False
+
+ self._pgn.current = curpage
+ self._pgn.number = numpages
+ self._pgn.more = more
+
+ # Récupération de la liste d'évènements.
+
+ if not parent.find('tr', attrs = {'class': ['vide']}):
+ for elt in parent.find_all('tr', recursive = False):
+ try:
+ assert 'entete' in elt['class']
+ continue
+ except:
+ pass
+ try:
+ assert 'pagination' in elt['class']
+ continue
+ except:
+ pass
+
+ ch = iter(elt.find_all('td'))
+
+ edate = next(ch).text.strip()
+ ename = next(ch).text.strip()
+ etype = next(ch).text.strip()
+ eobjs = next(ch)
+ edesc = next(ch).text.strip()
+
+ # Time decoding.
+
+ d, t = edate.split()
+ day, mon, year = map(int, d.split('/'))
+ hour, min, sec = map(int, t.split(':'))
+ dt = _datetime(year, mon, day, hour, min, sec)
+
+ # Operation creation.
+
+ op = _Operation()
+ op.time = dt
+ op.typename = etype
+ op.author = ename
+ op.fields = edesc
+
+ # Objects decoding.
+
+ objs = []
+ for link in eobjs.find_all('a'):
+ name = link.text
+ url = _urlparse(link['href'])
+ if url.path == stprefix:
+ st = _Structure()
+ st.iid = _parse_qs(url.query)['id'][0]
+ st.name = name
+ op.related.add(st)
+ elif url.path == adprefix:
+ ad = _Adherent()
+ ad.iid = _parse_qs(url.query)['id'][0]
+ ad.name = name
+ op.related.add(ad)
+ elif url.path == irprefix:
+ ir = _RallyRegistration()
+ ir.iid = _parse_qs(url.query)['id'][0]
+ ir.name = name
+ op.related.add(ir)
+ elif url.path == cpprefix:
+ cp = _Camp()
+ cp.iid = _parse_qs(url.query)['IdCamp'][0]
+ cp.name = name
+ op.related.add(cp)
+ elif url.path == laprefix:
+ la = _Place()
+ la.iid = _parse_qs(url.query)['id'][0]
+ la.name = name
+ op.related.add(la)
+
+ return elts
+
+ def __feed_html_lieu(self, content):
+ """ Decode the HTML place from a BeautifulSoup decoded
+ content and feed it into the manager's places. """
+
+ parent = content.find(id = 'ctl00__upMainContent')
+ rp = 'ctl00_MainContent__resume__'
+
+ place = Place()
+
+ # Informations générales: Libellé.
+
+ lib = parent.find(id = f'{rp}lbLibelle')
+ place.name = lib.text
+
+ # Informations générales: Description.
+
+ desc = parent.find(id = f'{rp}lbDescription')
+ place.description = desc.text
+
+ # Informations générales: Fiche. TODO
+ # XXX: absence de champ… faut voir ce que ça donne quand c'est rempli ?
+
+ # Coordonnées: Adresse (lignes).
+ # Avec plusieurs lignes du type `lbLigne1`, `lbLigne2`, `lbLigne3`.
+ # Certaines lignes peuvent ne pas être présentes, e.g. `lbLigne2`
+ # peut manquer.
+
+ def lines():
+ for i in range(1, 4):
+ try:
+ aid = f'{rp}resumeAdresse__lbLigne{i}'
+ al = parent.find(id = aid)
+ assert al != None
+ except:
+ continue
+ yield al.text.strip()
+
+ place.address.lines = '\n'.join(lines())
+
+ # Coordonnées: Adresse (code postal).
+
+ cp = parent.find(id = f'{rp}resumeAdresse__lbCodePostal')
+ place.address.city.postal_code = cp.text
+
+ # Coordonnées: Adresse (nom de la commune).
+
+ vil = parent.find(id = f'{rp}resumeAdresse__lbVille')
+ place.address.city.name = vil.text
+
+ # Coordonnées: Adresse (pays).
+
+ pays = parent.find(id = f'{rp}resumeAdresse__lbPays')
+ place.address.city.country = pays.text
+
+ # Coordonnées: département administratif. TODO
+ # Au format `XX - Nom du département` (où XX représente le numéro).
+
+ dept = parent.find(id = f'{rp}lbDepartementAdministratif')
+ dept = dept.text
+
+ # Coordonnées: étranger. TODO
+ # "Oui" ou "Non" selon si le lieu se trouve en France ou non… ?
+
+ etr = parent.find(id = f'{rp}lbEtranger')
+ etr = (True, False)[etr.text == 'Non']
+
+ # Coordonnées: continent. TODO
+ # Nom du continent, e.g. « Europe ».
+
+ cont = parent.find(id = f'{rp}lbContinent')
+ cont = cont.text
+
+ # Coordonnées: numéro de téléphone.
+ # Numéro de téléphone fixe associé au lieu.
+
+ phone = parent.find(id = f'{rp}lbTelephone')
+ phone = phone.text
+
+ if phone:
+ place.phones.add(phone, OP_MAIN)
+
+ # Coordonnées: numéro de fax.
+ # Numéro de fax associé au lieu.
+
+ fax = parent.find(id = f'{rp}lbFax')
+ fax = fax.text
+
+ if fax:
+ place.phones.add(fax, OP_FAX)
+
+ # Coordonnées: adresse de courriel.
+ # Adresse de courriel associée au lieu.
+
+ email = parent.find(id = f'{rp}lbCourriel')
+ email = email.text
+
+ if email:
+ place.emails.add(email, OE_MAIN)
+
+ # Informations complémentaires: numéro J&S. TODO
+ # XXX: ?? (vide sur les lieux explorés).
+
+ numjs = parent.find(id = f'{rp}lbNumeroJS')
+ numjs = numjs.text
+
+ # Informations complémentaires: hébergement "dur". TODO
+ # "Oui" s'il y a un hébergement en "dur" sur le lieu, "Non" sinon.
+
+ hebd = parent.find(id = f'{rp}lbHebergementDur')
+ hebd = (True, False)[hebd.text == 'Non']
+
+ # Informations complémentaires: numéro de local. TODO
+ # XXX: ?? (vide sur les lieux explorés).
+
+ numloc = parent.find(id = f'{rp}lbNumeroLocal')
+ numloc = numloc.text
+
+ # Informations complémentaires: propriétaire. TODO
+ # Nom du propriétaire, e.g. « Bertrand DUPONT » (format libre).
+
+ prop = parent.find(id = f'{rp}lbProprietaire')
+ prop = prop.text
+
+ # Informations complémentaires: adresse du propriétaire. TODO
+ # XXX: ?? (vide sur les lieux explorés).
+
+ addrp = parent.find(id = f'{rp}lbAdresseProprietaire')
+ addrp = addrp.text
+
+ # Accès: numéro de carte IGN. TODO
+ # XXX: ?? (vide sur les lieux explorés).
+
+ ign = parent.find(id = f'{rp}lbNumeroCarteIGN')
+ ign = ign.text
+
+ # Accès: accès voiture. TODO
+ # "Oui" si le lieu est accessible en voiture, "Non" sinon.
+
+ voit = parent.find(id = f'{rp}lbAccesVoiture')
+ voit = (True, False)[voit.text == 'Non']
+
+ # Accès: distance de la gare la plus proche. TODO
+ # Au format libre (e.g. « 5km »).
+
+ gard = parent.find(id = f'{rp}lbDistanceGare')
+ gard = gard.text
+
+ # Accès: nom de la gare la plus proche. TODO
+ # Au format libre (e.g. « Saint julien du Sault »).
+
+ garn = parent.find(id = f'{rp}lbNomGare')
+ garn = garn.text
+
+ # Accès: distance de l'arrêt de bus le plus proche. TODO
+ # Au format libre.
+
+ busd = parent.find(id = f'{rp}lbDistanceArretBus')
+ busd = busd.text
+
+ # Accès: nom de l'arrêt de bus le plus proche. TODO
+ # Au format libre.
+
+ busn = parent.find(id = f'{rp}lbNomArretBus')
+ busn = busn.text
+
+ return [place]
+
+# End of file.
diff --git a/sgdfi/_repr.py b/sgdfi/_repr.py
new file mode 100755
index 0000000..72cfc81
--- /dev/null
+++ b/sgdfi/_repr.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+#******************************************************************************
+# Copyright (C) 2018 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr>
+# This file is part of the sgdfi project, which is MIT-licensed.
+#******************************************************************************
+""" Main object to interact with SGDF's intranet. """
+
+import regex as _re
+
+from ._util import IID, Enum as _Enum, \
+ Base as _Base, Property as _Property, \
+ IIDProperty as _IIDProperty, DateProperty as _DateProperty, \
+ BoolProperty as _BoolProperty, EnumProperty as _EnumProperty, \
+ ArrayProperty as _ArrayProperty, TextProperty as _TextProperty
+
+__all__ = ["IID", "Structure", "Adherent", "RallyRegistration", "Camp",
+ "Place", "Operation", "OperationType"]
+
+class Structure(_Base):
+ """ A structure (unit, group, territory, …). """
+
+ iid = _IIDProperty()
+ name = _TextProperty()
+
+class Adherent(_Base):
+ """ A person who is part of the organization. """
+
+ iid = _IIDProperty()
+ name = _TextProperty()
+
+class RallyRegistration(_Base):
+ """ A registration to an event common to several structures. """
+
+ iid = _IIDProperty()
+ name = _TextProperty()
+
+class Camp(_Base):
+ """ A scout camp. """
+
+ iid = _IIDProperty()
+ name = _TextProperty()
+
+class Place(_Base):
+ """ A place. """
+
+ iid = _IIDProperty()
+ name = _TextProperty()
+ description = _TextProperty()
+ address = _TextProperty(doc = "Lines for the address of the place.")
+ postal_code = _TextProperty()
+ town = _TextProperty(doc = "Name of the town to which the " \
+ "place is related.")
+ country = _TextProperty()
+ department = _TextProperty(doc = "Name of the related " \
+ "administrative department.")
+ out_of_france = _BoolProperty()
+ continent = _TextProperty(doc = "Name of the continent the place is in.")
+ phone = _TextProperty()
+ fax = _TextProperty()
+ email = _TextProperty()
+ js = _TextProperty(doc = "J&S number for the place.")
+ hardwall = _BoolProperty(doc = "Does the place has permanent " \
+ "installations? (hébergement en dur)")
+ localnumber = _TextProperty()
+ owner = _TextProperty()
+ owner_address = _TextProperty()
+ ign_num = _TextProperty(doc = "Related IGN map number.")
+ car_access = _BoolProperty()
+ closest_train_station = _TextProperty()
+ closest_train_station_distance = _TextProperty()
+ closest_bus_stop = _TextProperty()
+ closest_bus_stop_distance = _TextProperty()
+
+# ---
+# Operations (called events on the intranet).
+# ---
+
+class _OperationFieldsProperty(_Property):
+ """ Operation fields. """
+
+ def init(self, lastfield = None, sep = ','):
+ pattern = f'([^{sep}\(]*(?P<par>\(([^\(\)]*(?&par)*)*\))*)*'
+
+ self.__value = None
+ self.__reg = _re.compile(pattern, _re.S)
+ self.__sep = sep
+
+ def get(self):
+ return self.__value
+
+ def set(self, value):
+ if value is None:
+ self.__value = None
+ return
+
+ if type(value) != str:
+ raise ValueError("fields should be string")
+
+ fields = {}
+ lastfield = None
+ for k in self.__reg.finditer(value):
+ ma = k.group(0)
+ if not ma:
+ continue
+
+ # From here, we have a valid group.
+ # If we have reached the last field already, just append it to
+ # the last key. Otherwise, check if it is a new group or not.
+
+ if lastfield and lastkey == lastfield:
+ fields[lastkey] += sep + ma
+ continue
+
+ g = ma.split(':')
+ if len(g) < 2:
+ if not lastkey:
+ continue
+
+ fields[lastkey] += self.__sep + ma
+ continue
+
+ # New group!
+
+ lastkey = g[0].strip()
+ fields[lastkey] = ':'.join(g[1:]).lstrip()
+
+ self.__value = fields
+
+ def delete(self):
+ self.__value = None
+
+class OperationType(_Enum):
+ """ The default type (unknown). """
+ UNKNOWN = 0
+
+ # TODO: other types.
+
+class Operation(_Base):
+ """ An event on the website.. """
+
+ time = _DateProperty()
+ type = _EnumProperty(enum = OperationType,
+ default = OperationType.UNKNOWN)
+ typename = _TextProperty()
+ author = _TextProperty()
+ fields = _OperationFieldsProperty()
+ related = _ArrayProperty(types = (Structure, Adherent,
+ RallyRegistration, Camp, Place))
+
+# End of file.
diff --git a/sgdfi/_session.py b/sgdfi/_session.py
new file mode 100755
index 0000000..f7b53aa
--- /dev/null
+++ b/sgdfi/_session.py
@@ -0,0 +1,315 @@
+#!/usr/bin/env python3
+#******************************************************************************
+# Copyright (C) 2018 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr>
+# This file is part of the sgdfi project, which is MIT-licensed.
+#******************************************************************************
+""" Main object to interact with SGDF's intranet. """
+
+from os import linesep as _linesep
+from sys import stderr as _stderr
+from time import strftime as _strftime, localtime as _localtime, \
+ clock_gettime as _getclocktime, CLOCK_MONOTONIC as _MONOCLOCK
+from io import StringIO as _StringIO
+from itertools import count as _count
+from json import dumps as _jsondumps
+
+from requests import Session as _Session
+from bs4 import BeautifulSoup as _BeautifulSoup
+
+from ._manager import Manager as _Manager
+from ._repr import IID as _IID
+
+_monotime = lambda: _getclocktime(_MONOCLOCK)
+
+class RedirectError(Exception):
+ """ The response is a redirection. """
+
+ def __init__(self, location):
+ super().__init__(f"Was redirected to {repr(location)}")
+ self.__location = location
+
+ @property
+ def location(self):
+ """ The location to which we have been redirected. """
+
+ return self.__location
+
+# ---
+# Définition de l'objet principal.
+# ---
+
+class Session(_Manager):
+ """ Class for interacting with the intranet. """
+
+ def __init__(self, base = 'https://intranet.sgdf.fr',
+ user = None, pw = None, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.__base = base
+ self.__user = user
+ self.__pw = pw
+ self.__session = _Session()
+
+ # Connexion.
+
+ self.login()
+
+ def __repr__(self):
+ return f"{self.__class__.__name__}(user = {repr(self.__user)})"
+
+ def __log(self, *msg):
+ prefix = f"[{_strftime('%Y-%m-%d %H:%M:%S', _localtime())} I]"
+ _stderr.write(f"{prefix} {' '.join(msg)}{_linesep}")
+
+ # ---
+ # Properties.
+ # ---
+
+ @property
+ def user(self):
+ """ Username set. """
+
+ return self.__user
+
+ @property
+ def pw(self):
+ """ Password set. """
+
+ return self.__pw
+
+ @property
+ def password(self):
+ """ Alias for `pw`. """
+
+ return self.pw
+
+ # ---
+ # Base utilities.
+ # ---
+
+ METHOD_BASIC = 1
+ METHOD_FORM = 2
+ METHOD_AJAX = 3
+
+ def get_page(self, path, args = {}, method = METHOD_BASIC, hint = None):
+ """ Gather a page:
+ - as a normal page using GET (`METHOD_BASIC`).
+ - as a form using POST (`METHOD_FORM`).
+ - as a page fragment using POST with AJAX (`METHOD_AJAX`).
+
+ The hint is for decoding the content, according to the
+ context. """
+
+ if not method in (self.METHOD_BASIC, self.METHOD_FORM, \
+ self.METHOD_AJAX):
+ raise ValueError("invalid method")
+
+ mname = ("basic", "form", "ajax")[method - 1]
+ self.__log(f"Get a page using {mname} method")
+
+ def ret(r):
+ """ Treat the response to know what to return """
+
+ status_code = r.status_code
+
+ # Check if it is a redirection.
+
+ if status_code in (301, 302):
+ loc = r.headers['Location']
+ self.__log(f"({r.status_code}) {loc}")
+
+ raise RedirectError(loc)
+
+ # Check if we ought to ignore the thing.
+
+ if hint == 'ignore':
+ self.__log(f"Ignore normal response.")
+ return None
+
+ # Décodage en fonction du code de retour.
+
+ ct = r.headers['Content-Type'].split(';')[0].strip()
+
+ htext = f" with hint {repr(hint)}" if hint != None else ""
+ if ct == 'text/plain':
+ self.__log(f"Decoding AJAX response{htext}.")
+
+ return self.feed(r.text, 'ajax', hint)
+ elif ct == 'text/csv':
+ self.__log(f"Decoding CSV content{htext}.")
+
+ return self.feed(r.text, 'csv', hint)
+ elif ct == 'text/html':
+ self.__log(f"Decoding HTML content{htext}.")
+
+ return self.feed(r.text, 'html', hint)
+
+ self.__log(f"Unmanaged Content-Type {repr(ct)}")
+ self.__log(f"Falling back on HTML decoding.")
+
+ return self.feed(r.text, 'html', hint)
+
+ # Définition des headers.
+
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:58.0) ' \
+ 'Gecko/20100101 Firefox/58.0',
+ 'Cache-Control': 'no-cache',
+ 'Upgrade-Insecure-Requests': '1',
+ 'Referer': self.__base + path,
+ 'DNT': '1'
+ }
+
+ # ---
+ # Récupération de la version GET de la page.
+ # ---
+
+ # Récupération à proprement parler.
+
+ tm = _monotime()
+ r = self.__session.get(self.__base + path, headers = headers)
+ self.__log(f"Page GET request delay: {_monotime() - tm}s")
+
+ # Si la méthode est un GET basique, on peut s'arrêter là.
+
+ if method == self.METHOD_BASIC:
+ return ret(r)
+
+ # Décodage du contenu récupéré et récupération des valeurs par
+ # défaut de l'ensemble des champs, dont `__EVENTVALIDATION`
+ # qui sert de cookie CSRF.
+
+ df = {}
+
+ it = _BeautifulSoup(r.text, 'lxml').find_all('input',
+ recursive = True)
+ for elt in it:
+ try:
+ name = elt['name']
+ except:
+ continue
+ try:
+ value = elt['value']
+ except:
+ value = ''
+
+ df[name] = value
+
+ # ---
+ # Préparation des paramètres POST (payload).
+ # ---
+
+ # Tout d'abord, ajoutons les données par défaut, à savoir ce
+ # qu'il y a dans la page.
+
+ payload = {}
+ payload.update(df)
+
+ # Ensuite, ajoutons les arguments de l'utilisateur.
+
+ def recursive_args(elements, prefix = ''):
+ for key in elements:
+ value = elements[key]
+ if isinstance(value, dict):
+ recursive_args(value, prefix + key + '$')
+ continue
+
+ payload[prefix + key] = value
+
+ recursive_args(args)
+
+ # ---
+ # Préparation des cookies et exécution de la requête.
+ # ---
+
+ # Ajustement des headers pour de l'AJAX.
+
+ if method == self.METHOD_AJAX:
+ headers.update({
+ 'X-MicrosoftAjax': 'Delta=true',
+ 'X-Requested-With': 'XMLHttpRequest'})
+
+ # Exécution de la requête.
+
+ tm = _monotime()
+ r = self.__session.post(self.__base + path, headers = headers,
+ data = payload, allow_redirects = False)
+ self.__log(f"Page POST request delay: {_monotime() - tm}s")
+
+ return ret(r)
+
+ # ---
+ # Usage.
+ # ---
+
+ def login(self):
+ """ Gather session data (cookies) within the internal session. """
+
+ try:
+ temp = self.get_page('/Default.aspx', {
+ 'ctl00': {
+ 'MainContent': {
+ 'login': self.__user,
+ 'password': self.__pw}}},
+ method = self.METHOD_FORM,
+ hint = 'ignore')
+ except RedirectError:
+ # We have successfully been redirected!
+ return
+
+ # FIXME: the credentials were invalid.
+
+ raise ValueError("invalid credentials")
+
+ def _get_ops_page(self, ent_type, ent_iid, page):
+ """ Get an operation page. """
+
+ path = '/Specialisation/Sgdf/popups/JournalEvenements.aspx' \
+ f'?typeEntite={ent_type}&idEntite={ent_iid.urlsafe()}'
+
+ return self.get_page(path, {
+ '__EVENTARGUMENT': f"Page${page}",
+ '__EVENTTARGET': 'ctl00$Popup$_evenements$_gvEvenements'},
+ method = self.METHOD_FORM,
+ hint = 'operations')
+
+ def get_ops(self, ent_type, ent_id):
+ """ Get the operations corresponding to something. """
+
+ try:
+ ent_type = int(ent_type)
+ assert 1 <= ent_type <= 25
+ ent_type = str(ent_type)
+ except:
+ raise ValueError("Entry type should be an integer between.")
+ ent_iid = _IID(ent_id)
+
+ # ---
+ # Récupération des évènements.
+ # ---
+
+ activities = []
+
+ for page in _count(1):
+ # Récupération du document et décodage.
+
+ resp = self._get_ops_page(ent_type, ent_iid, page)
+
+ # Si `curpage < page`, alors c'est un bug connu de l'intranet
+ # où une page est déclarée mais inaccessible, on arrête donc là.
+
+ if self._pgn.current < page:
+ break
+
+ # Ajout des activités à la liste totale.
+
+ activities.extend(resp)
+
+ # Vérifions si on est arrivés au bout.
+
+ if page == self._pgn.number and not self._pgn.more:
+ break
+
+ return activities
+
+# End of file.
diff --git a/sgdfi/_util.py b/sgdfi/_util.py
new file mode 100755
index 0000000..def9ecd
--- /dev/null
+++ b/sgdfi/_util.py
@@ -0,0 +1,401 @@
+#!/usr/bin/env python3
+#******************************************************************************
+# Copyright (C) 2018 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr>
+# This file is part of the sgdfi project, which is MIT-licensed.
+#******************************************************************************
+""" Base objects for the intranet objects representations, never used
+ directly. """
+
+from inspect import getargspec as _getargspec
+from urllib.parse import quote as _quote
+from enum import Enum
+from datetime import datetime as _datetime
+from base64 import b64decode as _b64decode, b64encode as _b64encode
+
+from pytz import timezone as _timezone
+
+__all__ = ["IID", "Enum",
+ "Property", "IIDProperty", "DateProperty", "BoolProperty", "EnumProperty",
+ "ArrayProperty", "TextProperty", "Base"]
+
+# ---
+# Attribute helpers.
+# ---
+
+class IID:
+ """ Identifier for any resource on the intranet. """
+
+ # Three formats are managed:
+ # - a 16-byte sequence.
+ # - the equivalent in base64 as a byte sequence.
+ # - the equivalent as text.
+
+ def __init__(self, value):
+ if isinstance(value, IID):
+ self.__val = bytes(value)
+ return
+
+ validbyteident = lambda x: len(x) == 16
+
+ if isinstance(value, str):
+ value = str(value)
+ try:
+ value = _unquote(value)
+ except:
+ pass
+
+ try:
+ b = _b64decode(value)
+ assert validbyteident(b)
+ self.__val = b
+ return
+ except:
+ pass
+ elif isinstance(value, bytes):
+ value = bytes(value)
+
+ try:
+ b = value.decode('ASCII')
+ try:
+ b = _unquote(b)
+ except:
+ pass
+
+ b = _b64decode(b)
+ assert validbyteident(b)
+ self.__val = b
+ return
+ except:
+ try:
+ assert validbyteident(value)
+ self.__val = value
+ return
+ except:
+ pass
+
+ raise ValueError(f"unsuitable iid: {repr(value)}")
+
+ def __str__(self):
+ return _b64encode(self.__val).decode('ASCII')
+
+ def __repr__(self):
+ return str(self)
+
+ def __bytes__(self):
+ return self.__val
+
+ def urlsafe(self):
+ """ Get a URL-safe version of the identifier. """
+
+ return _quote(_b64encode(self.__val))
+
+class _Array:
+ """ An array of certain objects, for the base class. """
+
+ def __init__(self, types = ()):
+ self.__list = []
+ self.__types = types
+
+ def __repr__(self):
+ return repr(self.__list)
+
+ def __iter__(self):
+ return iter(self.__list)
+
+ def __len__(self):
+ return len(self.__list)
+
+ def __getitem__(self, x):
+ return self.__list[x]
+
+ def add(self, elt):
+ """ Add an element into the array. """
+
+ if self.__types and not any(isinstance(elt, t) for t in self.__types):
+ raise TypeError(f"element to add is of unknown type {type(elt)}")
+ if any(elt is e or elt == e for e in self.__list):
+ raise ValueError("element already inserted")
+ self.__list.append(elt)
+
+ def empty(self):
+ """ Empty the array. """
+
+ self.__list = []
+
+# ---
+# Attributes.
+# ---
+
+class Property:
+ """ A base property. """
+
+ def __init__(self, doc = None, *args, **kwargs):
+ self._doc = doc
+ kwargs['doc'] = doc
+
+ try:
+ spec = _getargspec(self.init)
+ if not 'doc' in spec.args and spec.varargs is None and \
+ spec.keywords is None:
+ del kwargs['doc']
+ except:
+ pass
+
+ self.init(*args, **kwargs)
+
+ def init(self, *args, **kwargs):
+ """ Normal initialize method. """
+
+ self.doc = "The default property."
+
+ def doc(self, element, name):
+ """ The documentation string method. """
+
+ if self.__doc is not None:
+ return self.__doc
+ return self.defaultdoc(element, name)
+
+ def defaultdoc(self, element, name):
+ """ A default documentation string method. """
+
+ return f"A default property for a {element.lower()}."
+
+ def defaultdoc(self, element, name):
+ """ """
+
+ def get(self):
+ """ Get the current property value. """
+
+ return None
+
+ def set(self, value):
+ """ Set the current property value. """
+
+ pass
+
+ def delete(self):
+ """ Delete the current property value. """
+
+ pass
+
+class IIDProperty(Property):
+ """ An intranet identifier property. """
+
+ def init(self):
+ self.__iid = None
+
+ def defaultdoc(self, element, name):
+ return f"The intranet identifier for a {element.lower()}."
+
+ def get(self):
+ return self.__iid
+
+ def set(self, value):
+ if value is None:
+ self.__iid = None
+ else:
+ self.__iid = IID(value)
+
+ def delete(self):
+ self.__iid = value
+
+class DateProperty(Property):
+ """ A date property. """
+
+ def init(self):
+ self.__date = None
+
+ def defaultdoc(self, element, name):
+ return f"The {name.replace('_', ' ')} timestamp for the {element}."
+
+ def get(self):
+ return self.__date
+
+ def set(self, value):
+ if value is None:
+ self.__date = value
+ return
+ elif not isinstance(value, _datetime):
+ raise TypeError("expected None or an instance of datetime")
+
+ if value.tzinfo is None:
+ value = value.replace(tzinfo = _timezone('Europe/Paris'))
+ self.__date = value
+
+ def delete(self):
+ self.__date = value
+
+class BoolProperty(Property):
+ """ A boolean property. """
+
+ def init(self, default = False):
+ self.__default = bool(default)
+ self.__value = self.__default
+
+ def defaultdoc(self, element, name):
+ return f"The {name.replace('_', ' ')} doc for the {element}."
+
+ def get(self):
+ return self.__value
+
+ def set(self, value):
+ if value is None:
+ self.__value = self.__default
+ else:
+ self.__value = bool(value)
+
+ def delete(self):
+ self.__value = self.__default
+
+class EnumProperty(Property):
+ """ An enumeration property. """
+
+ def init(self, enum = Enum, default = None):
+ if default is not None:
+ try:
+ default = enum(default)
+ except ValueError:
+ msg = "Default should be None or a valid enum value."
+ raise ValueError(msg) from None
+
+ self.__enum = enum
+ self.__default = default
+ self.__value = default
+
+ def defaultdoc(self, element, name):
+ return f"The {name.replace('_', ' ')} constant for " \
+ f"the {element.lower()}."
+
+ def get(self):
+ return self.__value
+
+ def set(self, value):
+ if value is None:
+ self.__value = self.__default
+ else:
+ self.__value = self.__enum(value)
+
+ def delete(self):
+ self.__value = self.__default
+
+class ArrayProperty(Property):
+ """ An array made of different objets.
+ `types` are the accepted types (an empty array representing that
+ all types are accepted). """
+
+ def init(self, types = ()):
+ self.__array = _Array(types)
+
+ def defaultdoc(self, element, name):
+ return f"The {name.replace('_', ' ')} array for the {element.lower()}."
+
+ def get(self):
+ return self.__array
+
+ def set(self, value):
+ if value is None:
+ self.__array.empty()
+ return
+
+ raise ValueError("non settable")
+
+ def delete(self):
+ self.__array.empty()
+
+class TextProperty(Property):
+ """ A text property.
+ `lines` represents the maximum number of allowed lines.
+ `maxchars` represents the maximum number of characters. """
+
+ def init(self, lines = 0, maxchars = 0):
+ self.__value = None
+ self.__lines = lines
+ self.__maxchars = maxchars
+
+ def get(self):
+ return self.__value
+
+ def set(self, value):
+ if value is None:
+ self.__value = None
+ return
+
+ value = str(value)
+
+ # Manage the lines.
+
+ value = value.splitlines()
+ lines = self.__lines
+ if lines < 0:
+ lines = len(value)
+
+ tab = ['\n'.join(value[:lines])] if lines != 0 else []
+ tab += value[lines:]
+ value = ' '.join(tab)
+ del tab
+
+ # Manage the maximum number of characters.
+
+ maxchars = self.__maxchars
+ if maxchars > 0 and len(value) > maxchars:
+ msg = f"{name}: max text length exceeded (> {maxchars})"
+ raise ValueError(msg)
+
+ self.__value = value
+
+ def delete(self):
+ self.__value = None
+
+# ---
+# Main object class.
+# ---
+
+class Base:
+ """ The base class for all complex objects. """
+
+ def __repr__(self):
+ def getproperty(self, name):
+ return super().__getattribute__(name)
+
+ attrs = lambda: (f"{i} = {repr(getproperty(self, i).get())}" \
+ for i in dir(self) if not i.startswith('_') \
+ and isinstance(getproperty(self, i), Property))
+
+ return f"{self.__class__.__name__}({', '.join(attrs())})"
+
+ def __getattribute__(self, name):
+ try:
+ attr = super().__getattribute__(name)
+ except AttributeError as e:
+ raise e from None
+
+ if name.startswith('_') or not isinstance(attr, Property):
+ return attr
+
+ if isinstance(attr, Property):
+ return attr.get()
+ return attr
+
+ def __setattr__(self, name, value):
+ if name.startswith('_'):
+ super().__setattr__(name, value)
+ return
+
+ attr = super().__getattribute__(name)
+
+ if not isinstance(attr, Property):
+ super().__setattr__(name, value)
+ attr.set(value)
+
+ def __delattr__(self, name):
+ if name.startswith('_'):
+ super().__delattr__(name, value)
+ return
+
+ attr = super().__getattribute__(name)
+
+ if not isinstance(attr, property):
+ super().__delattr__(name, value)
+ attr.delete()
+
+# End of file.
diff --git a/sgdfi/_version.py b/sgdfi/_version.py
new file mode 100644
index 0000000..c56d555
--- /dev/null
+++ b/sgdfi/_version.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+#******************************************************************************
+# Copyright (C) 2018 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr>
+# This file is part of the sgdfi project, which is MIT-licensed.
+#******************************************************************************
+""" This tiny submodule only contains the version, for other components to
+ include it more easily. """
+
+__all__ = ["version"]
+
+version = "20181002"
+
+# End of file.
diff --git a/test/__init__.py b/test/__init__.py
new file mode 100755
index 0000000..5cd40b4
--- /dev/null
+++ b/test/__init__.py
@@ -0,0 +1,11 @@
+#!/usr/bin/env python3
+#******************************************************************************
+# Copyright (C) 2018 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr>
+# This file is part of the sgdfi project, which is MIT-licensed.
+#******************************************************************************
+""" Unit tests for the `sgdfi` Python module. """
+
+# This file is only there to indicate that the folder is a module.
+# It doesn't actually contain code.
+
+# End of file.