base
This commit is contained in:
		
							
								
								
									
										176
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										176
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,176 +1,8 @@ | ||||
| # ---> Python | ||||
| # Byte-compiled / optimized / DLL files | ||||
| __pycache__/ | ||||
| *.py[cod] | ||||
| *$py.class | ||||
|  | ||||
| # C extensions | ||||
| *.so | ||||
|  | ||||
| # Distribution / packaging | ||||
| .Python | ||||
| build/ | ||||
| develop-eggs/ | ||||
| dist/ | ||||
| downloads/ | ||||
| eggs/ | ||||
| .eggs/ | ||||
| lib/ | ||||
| lib64/ | ||||
| parts/ | ||||
| sdist/ | ||||
| var/ | ||||
| wheels/ | ||||
| share/python-wheels/ | ||||
| *.egg-info/ | ||||
| .installed.cfg | ||||
| *.egg | ||||
| MANIFEST | ||||
|  | ||||
| # PyInstaller | ||||
| #  Usually these files are written by a python script from a template | ||||
| #  before PyInstaller builds the exe, so as to inject date/other infos into it. | ||||
| *.manifest | ||||
| *.spec | ||||
|  | ||||
| # Installer logs | ||||
| pip-log.txt | ||||
| pip-delete-this-directory.txt | ||||
|  | ||||
| # Unit test / coverage reports | ||||
| htmlcov/ | ||||
| .tox/ | ||||
| .nox/ | ||||
| .coverage | ||||
| .coverage.* | ||||
| .cache | ||||
| nosetests.xml | ||||
| coverage.xml | ||||
| *.cover | ||||
| *.py,cover | ||||
| .hypothesis/ | ||||
| .pytest_cache/ | ||||
| cover/ | ||||
|  | ||||
| # Translations | ||||
| *.mo | ||||
| *.pot | ||||
|  | ||||
| # Django stuff: | ||||
| *.log | ||||
| local_settings.py | ||||
| db.sqlite3 | ||||
| db.sqlite3-journal | ||||
|  | ||||
| # Flask stuff: | ||||
| instance/ | ||||
| .webassets-cache | ||||
|  | ||||
| # Scrapy stuff: | ||||
| .scrapy | ||||
|  | ||||
| # Sphinx documentation | ||||
| docs/_build/ | ||||
|  | ||||
| # PyBuilder | ||||
| .pybuilder/ | ||||
| target/ | ||||
|  | ||||
| # Jupyter Notebook | ||||
| .ipynb_checkpoints | ||||
|  | ||||
| # IPython | ||||
| profile_default/ | ||||
| ipython_config.py | ||||
|  | ||||
| # pyenv | ||||
| #   For a library or package, you might want to ignore these files since the code is | ||||
| #   intended to run in multiple environments; otherwise, check them in: | ||||
| # .python-version | ||||
|  | ||||
| # pipenv | ||||
| #   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. | ||||
| #   However, in case of collaboration, if having platform-specific dependencies or dependencies | ||||
| #   having no cross-platform support, pipenv may install dependencies that don't work, or not | ||||
| #   install all needed dependencies. | ||||
| #Pipfile.lock | ||||
|  | ||||
| # UV | ||||
| #   Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. | ||||
| #   This is especially recommended for binary packages to ensure reproducibility, and is more | ||||
| #   commonly ignored for libraries. | ||||
| #uv.lock | ||||
|  | ||||
| # poetry | ||||
| #   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. | ||||
| #   This is especially recommended for binary packages to ensure reproducibility, and is more | ||||
| #   commonly ignored for libraries. | ||||
| #   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control | ||||
| #poetry.lock | ||||
|  | ||||
| # pdm | ||||
| #   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. | ||||
| #pdm.lock | ||||
| #   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it | ||||
| #   in version control. | ||||
| #   https://pdm.fming.dev/latest/usage/project/#working-with-version-control | ||||
| .pdm.toml | ||||
| .pdm-python | ||||
| .pdm-build/ | ||||
|  | ||||
| # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm | ||||
| __pypackages__/ | ||||
|  | ||||
| # Celery stuff | ||||
| celerybeat-schedule | ||||
| celerybeat.pid | ||||
|  | ||||
| # SageMath parsed files | ||||
| *.sage.py | ||||
|  | ||||
| # Environments | ||||
| *.pyc | ||||
| .env | ||||
| .venv | ||||
| env/ | ||||
| venv/ | ||||
| ENV/ | ||||
| env.bak/ | ||||
| venv.bak/ | ||||
|  | ||||
| # Spyder project settings | ||||
| .spyderproject | ||||
| .spyproject | ||||
|  | ||||
| # Rope project settings | ||||
| .ropeproject | ||||
|  | ||||
| # mkdocs documentation | ||||
| /site | ||||
|  | ||||
| # mypy | ||||
| db.sqlite3 | ||||
| .mypy_cache/ | ||||
| .dmypy.json | ||||
| dmypy.json | ||||
|  | ||||
| # Pyre type checker | ||||
| .pyre/ | ||||
|  | ||||
| # pytype static type analyzer | ||||
| .pytype/ | ||||
|  | ||||
| # Cython debug symbols | ||||
| cython_debug/ | ||||
|  | ||||
| # PyCharm | ||||
| #  JetBrains specific template is maintained in a separate JetBrains.gitignore that can | ||||
| #  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore | ||||
| #  and can be added to the global gitignore or merged into this file.  For a more nuclear | ||||
| #  option (not recommended) you can uncomment the following to ignore the entire idea folder. | ||||
| #.idea/ | ||||
|  | ||||
| # Ruff stuff: | ||||
| .ruff_cache/ | ||||
|  | ||||
| # PyPI configuration file | ||||
| .pypirc | ||||
| .pytest_cache/ | ||||
| .coverage | ||||
|  | ||||
|   | ||||
							
								
								
									
										16
									
								
								Pipfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								Pipfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| [[source]] | ||||
| url = "https://pypi.org/simple" | ||||
| verify_ssl = true | ||||
| name = "pypi" | ||||
|  | ||||
| [packages] | ||||
| django = "*" | ||||
| django-environ = "*" | ||||
| djangorestframework = "*" | ||||
| requests = "*" | ||||
| python-dateutil = "*" | ||||
|  | ||||
| [dev-packages] | ||||
|  | ||||
| [requires] | ||||
| python_version = "3.13" | ||||
							
								
								
									
										212
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										212
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,212 @@ | ||||
| { | ||||
|     "_meta": { | ||||
|         "hash": { | ||||
|             "sha256": "e02bde8a8f8e5abfb9e7b093ecd81b4f54bc5af7756be42987f3ee1171bd228f" | ||||
|         }, | ||||
|         "pipfile-spec": 6, | ||||
|         "requires": { | ||||
|             "python_version": "3.13" | ||||
|         }, | ||||
|         "sources": [ | ||||
|             { | ||||
|                 "name": "pypi", | ||||
|                 "url": "https://pypi.org/simple", | ||||
|                 "verify_ssl": true | ||||
|             } | ||||
|         ] | ||||
|     }, | ||||
|     "default": { | ||||
|         "asgiref": { | ||||
|             "hashes": [ | ||||
|                 "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142", | ||||
|                 "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c" | ||||
|             ], | ||||
|             "markers": "python_version >= '3.9'", | ||||
|             "version": "==3.9.1" | ||||
|         }, | ||||
|         "certifi": { | ||||
|             "hashes": [ | ||||
|                 "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", | ||||
|                 "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5" | ||||
|             ], | ||||
|             "markers": "python_version >= '3.7'", | ||||
|             "version": "==2025.8.3" | ||||
|         }, | ||||
|         "charset-normalizer": { | ||||
|             "hashes": [ | ||||
|                 "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", | ||||
|                 "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45", | ||||
|                 "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", | ||||
|                 "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", | ||||
|                 "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", | ||||
|                 "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", | ||||
|                 "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d", | ||||
|                 "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", | ||||
|                 "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184", | ||||
|                 "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", | ||||
|                 "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b", | ||||
|                 "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64", | ||||
|                 "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", | ||||
|                 "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", | ||||
|                 "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", | ||||
|                 "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344", | ||||
|                 "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58", | ||||
|                 "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", | ||||
|                 "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", | ||||
|                 "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", | ||||
|                 "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", | ||||
|                 "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", | ||||
|                 "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", | ||||
|                 "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", | ||||
|                 "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", | ||||
|                 "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1", | ||||
|                 "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01", | ||||
|                 "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", | ||||
|                 "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58", | ||||
|                 "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", | ||||
|                 "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", | ||||
|                 "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2", | ||||
|                 "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a", | ||||
|                 "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", | ||||
|                 "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", | ||||
|                 "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5", | ||||
|                 "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb", | ||||
|                 "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f", | ||||
|                 "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", | ||||
|                 "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", | ||||
|                 "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", | ||||
|                 "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", | ||||
|                 "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7", | ||||
|                 "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", | ||||
|                 "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455", | ||||
|                 "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", | ||||
|                 "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4", | ||||
|                 "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", | ||||
|                 "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", | ||||
|                 "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", | ||||
|                 "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", | ||||
|                 "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", | ||||
|                 "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", | ||||
|                 "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", | ||||
|                 "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", | ||||
|                 "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", | ||||
|                 "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", | ||||
|                 "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa", | ||||
|                 "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", | ||||
|                 "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", | ||||
|                 "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", | ||||
|                 "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", | ||||
|                 "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", | ||||
|                 "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", | ||||
|                 "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02", | ||||
|                 "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", | ||||
|                 "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", | ||||
|                 "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", | ||||
|                 "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", | ||||
|                 "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", | ||||
|                 "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", | ||||
|                 "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", | ||||
|                 "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681", | ||||
|                 "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", | ||||
|                 "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", | ||||
|                 "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a", | ||||
|                 "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", | ||||
|                 "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", | ||||
|                 "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", | ||||
|                 "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", | ||||
|                 "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027", | ||||
|                 "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", | ||||
|                 "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", | ||||
|                 "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", | ||||
|                 "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", | ||||
|                 "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", | ||||
|                 "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", | ||||
|                 "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da", | ||||
|                 "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", | ||||
|                 "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f", | ||||
|                 "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", | ||||
|                 "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f" | ||||
|             ], | ||||
|             "markers": "python_version >= '3.7'", | ||||
|             "version": "==3.4.2" | ||||
|         }, | ||||
|         "django": { | ||||
|             "hashes": [ | ||||
|                 "sha256:0745b25681b129a77aae3d4f6549b62d3913d74407831abaa0d9021a03954bae", | ||||
|                 "sha256:2b2ada0ee8a5ff743a40e2b9820d1f8e24c11bac9ae6469cd548f0057ea6ddcd" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "markers": "python_version >= '3.10'", | ||||
|             "version": "==5.2.5" | ||||
|         }, | ||||
|         "django-environ": { | ||||
|             "hashes": [ | ||||
|                 "sha256:227dc891453dd5bde769c3449cf4a74b6f2ee8f7ab2361c93a07068f4179041a", | ||||
|                 "sha256:92fb346a158abda07ffe6eb23135ce92843af06ecf8753f43adf9d2366dcc0ca" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "markers": "python_version >= '3.9' and python_version < '4'", | ||||
|             "version": "==0.12.0" | ||||
|         }, | ||||
|         "djangorestframework": { | ||||
|             "hashes": [ | ||||
|                 "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", | ||||
|                 "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "markers": "python_version >= '3.9'", | ||||
|             "version": "==3.16.1" | ||||
|         }, | ||||
|         "idna": { | ||||
|             "hashes": [ | ||||
|                 "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", | ||||
|                 "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" | ||||
|             ], | ||||
|             "markers": "python_version >= '3.6'", | ||||
|             "version": "==3.10" | ||||
|         }, | ||||
|         "python-dateutil": { | ||||
|             "hashes": [ | ||||
|                 "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", | ||||
|                 "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", | ||||
|             "version": "==2.9.0.post0" | ||||
|         }, | ||||
|         "requests": { | ||||
|             "hashes": [ | ||||
|                 "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", | ||||
|                 "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "markers": "python_version >= '3.8'", | ||||
|             "version": "==2.32.4" | ||||
|         }, | ||||
|         "six": { | ||||
|             "hashes": [ | ||||
|                 "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", | ||||
|                 "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" | ||||
|             ], | ||||
|             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", | ||||
|             "version": "==1.17.0" | ||||
|         }, | ||||
|         "sqlparse": { | ||||
|             "hashes": [ | ||||
|                 "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", | ||||
|                 "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca" | ||||
|             ], | ||||
|             "markers": "python_version >= '3.8'", | ||||
|             "version": "==0.5.3" | ||||
|         }, | ||||
|         "urllib3": { | ||||
|             "hashes": [ | ||||
|                 "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", | ||||
|                 "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" | ||||
|             ], | ||||
|             "markers": "python_version >= '3.9'", | ||||
|             "version": "==2.5.0" | ||||
|         } | ||||
|     }, | ||||
|     "develop": {} | ||||
| } | ||||
							
								
								
									
										0
									
								
								arr_api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								arr_api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										3
									
								
								arr_api/admin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								arr_api/admin.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| from django.contrib import admin | ||||
|  | ||||
| # Register your models here. | ||||
							
								
								
									
										6
									
								
								arr_api/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								arr_api/apps.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| from django.apps import AppConfig | ||||
|  | ||||
|  | ||||
| class ArrApiConfig(AppConfig): | ||||
|     default_auto_field = 'django.db.models.BigAutoField' | ||||
|     name = 'arr_api' | ||||
							
								
								
									
										0
									
								
								arr_api/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								arr_api/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										3
									
								
								arr_api/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								arr_api/models.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| from django.db import models | ||||
|  | ||||
| # Create your models here. | ||||
							
								
								
									
										133
									
								
								arr_api/services.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								arr_api/services.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | ||||
| # arr_api/services.py | ||||
| import os | ||||
| import requests | ||||
| from datetime import datetime, timedelta, timezone | ||||
| from dateutil.parser import isoparse | ||||
|  | ||||
| # ENV-Fallbacks | ||||
| ENV_SONARR_URL = os.getenv("SONARR_URL", "") | ||||
| ENV_SONARR_KEY = os.getenv("SONARR_API_KEY", "") | ||||
| ENV_RADARR_URL = os.getenv("RADARR_URL", "") | ||||
| ENV_RADARR_KEY = os.getenv("RADARR_API_KEY", "") | ||||
| DEFAULT_DAYS = int(os.getenv("ARR_DEFAULT_DAYS", "30")) | ||||
|  | ||||
| class ArrServiceError(Exception): | ||||
|     pass | ||||
|  | ||||
| def _get(url, headers, params=None, timeout=5): | ||||
|     try: | ||||
|         r = requests.get(url, headers=headers, params=params or {}, timeout=timeout) | ||||
|         r.raise_for_status() | ||||
|         return r.json() | ||||
|     except requests.exceptions.RequestException as e: | ||||
|         raise ArrServiceError(str(e)) | ||||
|  | ||||
| def _abs_url(base: str, p: str | None) -> str | None: | ||||
|     if not p: | ||||
|         return None | ||||
|     return f"{base.rstrip('/')}{p}" if p.startswith("/") else p | ||||
|  | ||||
| def sonarr_calendar(days: int | None = None, base_url: str | None = None, api_key: str | None = None): | ||||
|     base = (base_url or ENV_SONARR_URL).strip() | ||||
|     key  = (api_key  or ENV_SONARR_KEY).strip() | ||||
|     if not base or not key: | ||||
|         return [] | ||||
|     d = days or DEFAULT_DAYS | ||||
|     start = datetime.now(timezone.utc) | ||||
|     end = start + timedelta(days=d) | ||||
|  | ||||
|     url = f"{base.rstrip('/')}/api/v3/calendar" | ||||
|     headers = {"X-Api-Key": key} | ||||
|     data = _get(url, headers, params={ | ||||
|         "start": start.date().isoformat(), | ||||
|         "end": end.date().isoformat(), | ||||
|         "unmonitored": "false", | ||||
|         "includeSeries": "true", | ||||
|     }) | ||||
|  | ||||
|     out = [] | ||||
|     for ep in data: | ||||
|         series = ep.get("series") or {} | ||||
|         # Poster finden | ||||
|         poster = None | ||||
|         for img in (series.get("images") or []): | ||||
|             if (img.get("coverType") or "").lower() == "poster": | ||||
|                 poster = img.get("remoteUrl") or _abs_url(base, img.get("url")) | ||||
|                 if poster: | ||||
|                     break | ||||
|  | ||||
|         aired = isoparse(ep["airDateUtc"]).isoformat() if ep.get("airDateUtc") else None | ||||
|         out.append({ | ||||
|             "seriesId": series.get("id"), | ||||
|             "seriesTitle": series.get("title"), | ||||
|             "seriesStatus": (series.get("status") or "").lower(), | ||||
|             "seriesPoster": poster, | ||||
|             "seriesOverview": series.get("overview") or "", | ||||
|             "seriesGenres": series.get("genres") or [], | ||||
|             "episodeId": ep.get("id"), | ||||
|             "seasonNumber": ep.get("seasonNumber"), | ||||
|             "episodeNumber": ep.get("episodeNumber"), | ||||
|             "title": ep.get("title"), | ||||
|             "airDateUtc": aired, | ||||
|             "tvdbId": series.get("tvdbId"), | ||||
|             "imdbId": series.get("imdbId"), | ||||
|             "network": series.get("network"), | ||||
|         }) | ||||
|     return [x for x in out if x["seriesStatus"] == "continuing"] | ||||
|  | ||||
| def radarr_calendar(days: int | None = None, base_url: str | None = None, api_key: str | None = None): | ||||
|     base = (base_url or ENV_RADARR_URL).strip() | ||||
|     key  = (api_key  or ENV_RADARR_KEY).strip() | ||||
|     if not base or not key: | ||||
|         return [] | ||||
|     d = days or DEFAULT_DAYS | ||||
|     start = datetime.now(timezone.utc) | ||||
|     end = start + timedelta(days=d) | ||||
|  | ||||
|     url = f"{base.rstrip('/')}/api/v3/calendar" | ||||
|     headers = {"X-Api-Key": key} | ||||
|     data = _get(url, headers, params={ | ||||
|         "start": start.date().isoformat(), | ||||
|         "end": end.date().isoformat(), | ||||
|         "unmonitored": "false", | ||||
|         "includeMovie": "true", | ||||
|     }) | ||||
|  | ||||
|     out = [] | ||||
|     for it in data: | ||||
|         movie = it.get("movie") or it | ||||
|         # Poster finden | ||||
|         poster = None | ||||
|         for img in (movie.get("images") or []): | ||||
|             if (img.get("coverType") or "").lower() == "poster": | ||||
|                 poster = img.get("remoteUrl") or _abs_url(base, img.get("url")) | ||||
|                 if poster: | ||||
|                     break | ||||
|  | ||||
|         out.append({ | ||||
|             "movieId": movie.get("id"), | ||||
|             "title": movie.get("title"), | ||||
|             "year": movie.get("year"), | ||||
|             "tmdbId": movie.get("tmdbId"), | ||||
|             "imdbId": movie.get("imdbId"), | ||||
|             "posterUrl": poster, | ||||
|             "overview": movie.get("overview") or "", | ||||
|             "inCinemas": movie.get("inCinemas"), | ||||
|             "physicalRelease": movie.get("physicalRelease"), | ||||
|             "digitalRelease": movie.get("digitalRelease"), | ||||
|             "hasFile": movie.get("hasFile"), | ||||
|             "isAvailable": movie.get("isAvailable"), | ||||
|         }) | ||||
|  | ||||
|     def is_upcoming(m): | ||||
|         for k in ("inCinemas", "physicalRelease", "digitalRelease"): | ||||
|             v = m.get(k) | ||||
|             if v: | ||||
|                 try: | ||||
|                     if isoparse(v) > datetime.now(timezone.utc): | ||||
|                         return True | ||||
|                 except Exception: | ||||
|                     pass | ||||
|         return False | ||||
|  | ||||
|     return [m for m in out if is_upcoming(m)] | ||||
							
								
								
									
										738
									
								
								arr_api/templates/arr_api/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										738
									
								
								arr_api/templates/arr_api/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,738 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="de"> | ||||
|  | ||||
| <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||
|     <title>Subscribarr – Übersicht</title> | ||||
|     <style> | ||||
|         :root { | ||||
|             --bg: #0b0b10; | ||||
|             --panel: #12121a; | ||||
|             --panel-b: #1f2030; | ||||
|             --accent: #3b82f6; | ||||
|             --muted: #9aa0b4; | ||||
|             --text: #e6e6e6; | ||||
|         } | ||||
|  | ||||
|         * { | ||||
|             box-sizing: border-box; | ||||
|         } | ||||
|  | ||||
|         body { | ||||
|             font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Arial, sans-serif; | ||||
|             background: var(--bg); | ||||
|             color: var(--text); | ||||
|             margin: 0; | ||||
|         } | ||||
|  | ||||
|         .wrap { | ||||
|             max-width: 1200px; | ||||
|             margin: 0 auto; | ||||
|             padding: 16px; | ||||
|         } | ||||
|  | ||||
|         h1 { | ||||
|             margin: 4px 0 12px; | ||||
|             font-size: clamp(1.2rem, 2.5vw, 1.6rem); | ||||
|         } | ||||
|  | ||||
|         /* Controls */ | ||||
|         .controls { | ||||
|             display: flex; | ||||
|             flex-wrap: wrap; | ||||
|             align-items: center; | ||||
|             gap: 8px; | ||||
|             margin-bottom: 16px; | ||||
|         } | ||||
|  | ||||
|         .controls form { | ||||
|             display: flex; | ||||
|             align-items: center; | ||||
|             gap: 8px; | ||||
|             flex: 1; | ||||
|             min-width: 220px; | ||||
|         } | ||||
|  | ||||
|         .controls input[type=text] { | ||||
|             flex: 1; | ||||
|             padding: 10px 12px; | ||||
|             border-radius: 10px; | ||||
|             border: 1px solid #2a2a34; | ||||
|             background: #111119; | ||||
|             color: var(--text); | ||||
|             font-size: 1rem; | ||||
|             min-width: 0; | ||||
|         } | ||||
|  | ||||
|         .controls button[type=submit] { | ||||
|             padding: 10px 14px; | ||||
|             border-radius: 10px; | ||||
|             border: 0; | ||||
|             background: var(--accent); | ||||
|             color: #fff; | ||||
|             cursor: pointer; | ||||
|         } | ||||
|  | ||||
|         .seg { | ||||
|             display: inline-flex; | ||||
|             background: #0f0f17; | ||||
|             border: 1px solid #28293a; | ||||
|             border-radius: 10px; | ||||
|             overflow: hidden; | ||||
|         } | ||||
|  | ||||
|         .seg a { | ||||
|             padding: 8px 12px; | ||||
|             color: var(--text); | ||||
|             text-decoration: none; | ||||
|         } | ||||
|  | ||||
|         .seg a.active { | ||||
|             background: var(--accent); | ||||
|             color: #fff; | ||||
|         } | ||||
|  | ||||
|         /* Cards */ | ||||
|         .grid { | ||||
|             display: grid; | ||||
|             grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); | ||||
|             gap: 14px; | ||||
|         } | ||||
|  | ||||
|         @media (min-width: 900px) { | ||||
|             .grid { | ||||
|                 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         .card { | ||||
|             background: var(--panel); | ||||
|             border: 1px solid var(--panel-b); | ||||
|             border-radius: 12px; | ||||
|             overflow: hidden; | ||||
|             display: flex; | ||||
|             gap: 12px; | ||||
|             padding: 12px; | ||||
|             transition: transform .08s ease, border-color .08s; | ||||
|             cursor: pointer; | ||||
|         } | ||||
|  | ||||
|         .card:active, | ||||
|         .card:hover { | ||||
|             transform: translateY(-2px); | ||||
|             border-color: #2a2b44; | ||||
|         } | ||||
|  | ||||
|         .poster { | ||||
|             width: 110px; | ||||
|             height: 165px; | ||||
|             background: #222233; | ||||
|             border-radius: 8px; | ||||
|             overflow: hidden; | ||||
|             flex: 0 0 auto; | ||||
|         } | ||||
|  | ||||
|         .poster img { | ||||
|             width: 100%; | ||||
|             height: 100%; | ||||
|             object-fit: cover; | ||||
|             display: block; | ||||
|         } | ||||
|  | ||||
|         .meta { | ||||
|             flex: 1 1 auto; | ||||
|             min-width: 0; | ||||
|         } | ||||
|  | ||||
|         .title { | ||||
|             font-weight: 600; | ||||
|             font-size: 1rem; | ||||
|             margin-bottom: 6px; | ||||
|             white-space: nowrap; | ||||
|             overflow: hidden; | ||||
|             text-overflow: ellipsis; | ||||
|         } | ||||
|  | ||||
|         .episodes { | ||||
|             max-height: 210px; | ||||
|             overflow: auto; | ||||
|             padding-right: 6px; | ||||
|         } | ||||
|  | ||||
|         .ep { | ||||
|             font-size: 0.92rem; | ||||
|             padding: 6px 0; | ||||
|             border-bottom: 1px dashed #25263a; | ||||
|         } | ||||
|  | ||||
|         .muted { | ||||
|             color: var(--muted); | ||||
|             font-size: 0.9rem; | ||||
|         } | ||||
|  | ||||
|         .movie-card { | ||||
|             background: var(--panel); | ||||
|             border: 1px solid var(--panel-b); | ||||
|             border-radius: 12px; | ||||
|             padding: 12px; | ||||
|         } | ||||
|  | ||||
|         .movie-card img { | ||||
|             width: 100%; | ||||
|             border-radius: 8px; | ||||
|             margin-bottom: 8px; | ||||
|             display: block; | ||||
|             height: auto; | ||||
|         } | ||||
|  | ||||
|         .section { | ||||
|             margin-top: 22px; | ||||
|         } | ||||
|  | ||||
|         .section h2 { | ||||
|             font-size: 1.1rem; | ||||
|             margin: 12px 0; | ||||
|         } | ||||
|  | ||||
|         /* Modal */ | ||||
|         .modal-backdrop { | ||||
|             position: fixed; | ||||
|             inset: 0; | ||||
|             background: rgba(10, 12, 20, .55); | ||||
|             backdrop-filter: blur(4px); | ||||
|             display: none; | ||||
|             align-items: center; | ||||
|             justify-content: center; | ||||
|             z-index: 1000; | ||||
|             padding: 12px; | ||||
|         } | ||||
|  | ||||
|         .modal { | ||||
|             width: min(960px, 100%); | ||||
|             max-height: 92vh; | ||||
|             overflow: auto; | ||||
|             background: linear-gradient(180deg, #10121b 0%, #0d0f16 100%); | ||||
|             border: 1px solid #2a2b44; | ||||
|             border-radius: 16px; | ||||
|             box-shadow: 0 24px 80px rgba(0, 0, 0, .6); | ||||
|         } | ||||
|  | ||||
|         .modal-header { | ||||
|             position: sticky; | ||||
|             grid-template-columns: auto 1fr auto; | ||||
|             top: 0; | ||||
|             z-index: 2; | ||||
|             display: grid; | ||||
|             grid-template-columns: 130px 1fr auto; | ||||
|             gap: 14px; | ||||
|             align-items: center; | ||||
|             padding: 16px; | ||||
|             background: rgba(13, 15, 22, .85); | ||||
|             backdrop-filter: blur(4px); | ||||
|             border-bottom: 1px solid #20223a; | ||||
|         } | ||||
|  | ||||
|         .m-poster { | ||||
|             display: flex; | ||||
|             flex-direction: column; | ||||
|             align-items: center; | ||||
|             gap: 10px; | ||||
|             width: 130px; | ||||
|             height: 195px; | ||||
|             border-radius: 10px; | ||||
|             overflow: hidden; | ||||
|             background: #222233; | ||||
|         } | ||||
|  | ||||
|         .m-poster img { | ||||
|             width: 100%; | ||||
|             height: 100%; | ||||
|             object-fit: cover; | ||||
|             display: block; | ||||
|         } | ||||
|  | ||||
|         .m-title { | ||||
|             font-size: 1.25rem; | ||||
|             font-weight: 750; | ||||
|             line-height: 1.2; | ||||
|             margin-bottom: 6px; | ||||
|         } | ||||
|  | ||||
|         .m-sub { | ||||
|             color: var(--muted); | ||||
|             font-size: .92rem; | ||||
|         } | ||||
|  | ||||
|         .badges { | ||||
|             display: flex; | ||||
|             flex-wrap: wrap; | ||||
|             gap: 6px; | ||||
|             margin-top: 8px; | ||||
|         } | ||||
|  | ||||
|         .badge { | ||||
|             padding: 4px 8px; | ||||
|             border-radius: 999px; | ||||
|             background: #171a26; | ||||
|             border: 1px solid #2a2b44; | ||||
|             font-size: .82rem; | ||||
|             color: #cfd3ea; | ||||
|         } | ||||
|  | ||||
|         .modal-close { | ||||
|             margin-left: auto; | ||||
|             align-self: start; | ||||
|             background: #1a1f33; | ||||
|             border: 1px solid #2a2b44; | ||||
|             color: #c9cbe3; | ||||
|             width: 36px; | ||||
|             height: 36px; | ||||
|             justify-self: end; | ||||
|             border-radius: 10px; | ||||
|             cursor: pointer; | ||||
|             display: grid; | ||||
|             place-items: center; | ||||
|             font-size: 1.4rem; | ||||
|             line-height: 1; | ||||
|             transition: transform .08s ease, background .12s ease, border-color .12s ease; | ||||
|         } | ||||
|  | ||||
|         .modal-close:hover { | ||||
|             background: #243055; | ||||
|             border-color: #3b4aa0; | ||||
|             transform: translateY(-1px); | ||||
|         } | ||||
|  | ||||
|         .modal-body { | ||||
|             padding: 16px; | ||||
|             display: grid; | ||||
|             grid-template-columns: 1fr; | ||||
|             gap: 16px; | ||||
|         } | ||||
|  | ||||
|         @media (min-width: 900px) { | ||||
|             .modal-body { | ||||
|                 grid-template-columns: 1.2fr .8fr; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         .section-block { | ||||
|             background: #101327; | ||||
|             border: 1px solid #20223a; | ||||
|             border-radius: 12px; | ||||
|             padding: 14px; | ||||
|         } | ||||
|  | ||||
|         .section-title { | ||||
|             font-size: 1rem; | ||||
|             font-weight: 650; | ||||
|             margin: 0 0 8px; | ||||
|         } | ||||
|  | ||||
|         .section-divider { | ||||
|             height: 1px; | ||||
|             background: #20223a; | ||||
|             margin: 10px 0; | ||||
|             opacity: .9; | ||||
|         } | ||||
|  | ||||
|         .ep-row { | ||||
|             border-bottom: 1px dashed #262947; | ||||
|             padding: 8px 0; | ||||
|             font-size: .94rem; | ||||
|         } | ||||
|  | ||||
|         .ep-row:last-child { | ||||
|             border-bottom: 0; | ||||
|         } | ||||
|  | ||||
|         /* control */ | ||||
|         .controls input[type=number] { | ||||
|             width: 90px; | ||||
|             padding: 10px 12px; | ||||
|             border-radius: 10px; | ||||
|             border: 1px solid #2a2a34; | ||||
|             background: #111119; | ||||
|             color: var(--text); | ||||
|             font-size: 1rem; | ||||
|             -moz-appearance: textfield; | ||||
|         } | ||||
|  | ||||
|         .btn-subscribe { | ||||
|             padding: 8px 14px; | ||||
|             border-radius: 10px; | ||||
|             background: #1f6f3a; | ||||
|             border: 1px solid #2a2b34; | ||||
|             color: #fff; | ||||
|             cursor: pointer; | ||||
|             font-weight: 600; | ||||
|             transition: background .15s ease, transform .08s ease; | ||||
|         } | ||||
|  | ||||
|         .btn-subscribe:hover { | ||||
|             background: #2b8f4d; | ||||
|         } | ||||
|  | ||||
|         .btn-subscribe:active { | ||||
|             transform: translateY(1px); | ||||
|         } | ||||
|  | ||||
|         .subscribed { | ||||
|             outline: 3px solid #1f6f3a; | ||||
|             /* grüne Markierung am Element */ | ||||
|             outline-offset: 2px; | ||||
|         } | ||||
|  | ||||
|  | ||||
|         .controls input[type=number]::-webkit-outer-spin-button, | ||||
|         .controls input[type=number]::-webkit-inner-spin-button { | ||||
|             -webkit-appearance: none; | ||||
|             margin: 0; | ||||
|         } | ||||
|  | ||||
|         .controls input[type=number]:focus { | ||||
|             border-color: var(--accent); | ||||
|             outline: none; | ||||
|         } | ||||
|  | ||||
|         .movie-card { | ||||
|             background: var(--panel); | ||||
|             border: 1px solid var(--panel-b); | ||||
|             border-radius: 12px; | ||||
|             padding: 12px; | ||||
|             cursor: pointer; | ||||
|             /* klickbar */ | ||||
|             transition: transform .08s ease, border-color .08s; | ||||
|             /* wie .card */ | ||||
|         } | ||||
|  | ||||
|         .movie-card:hover, | ||||
|         .movie-card:focus-within { | ||||
|             transform: translateY(-2px); | ||||
|             border-color: #2a2b44; | ||||
|         } | ||||
|  | ||||
|         .movie-card:active { | ||||
|             transform: translateY(0); | ||||
|             /* kleiner Tap-Feedback */ | ||||
|         } | ||||
|     </style> | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
|  | ||||
|     <div class="topbar" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px"> | ||||
|         <div></div> | ||||
|         <div style="display:flex;gap:8px;align-items:center"> | ||||
|             <div class="debug" title="Debug" | ||||
|                 style="padding:8px 10px;border:1px solid #2a2a34;border-radius:10px;background:#111119;color:#cfd3ea;font-size:.9rem;"> | ||||
|                 kind={{ kind }} · days={{ days }} · series={{ series_grouped|length }} · movies={{ movies|length }} | ||||
|             </div> | ||||
|             <a href="/settings/" class="btn" | ||||
|                 style="padding:8px 12px;border-radius:10px;border:1px solid #2a2a34;background:#111119;color:#fff;text-decoration:none"> | ||||
|                 ⚙️ Einstellungen | ||||
|             </a> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|  | ||||
|     <div class="wrap"> | ||||
|         <h1>Subscribarr</h1> | ||||
|  | ||||
|         <div class="controls"> | ||||
|             <form method="get" class="controls-form"> | ||||
|                 <input type="hidden" name="kind" value="{{ kind|default:'all' }}"> | ||||
|                 <input type="text" name="q" placeholder="Suche Serien/Filme…" value="{{ query|default:'' }}"> | ||||
|                 <input type="number" name="days" min="1" max="365" value="{{ days|default:30 }}" | ||||
|                     title="Zeitraum in Tagen"> | ||||
|                 <button type="submit">Suchen</button> | ||||
|             </form> | ||||
|  | ||||
|             <nav class="seg" aria-label="Typ filtern"> | ||||
|                 {% with qs=query|urlencode %} | ||||
|                 <a href="?kind=all&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}" | ||||
|                     class="{% if kind == 'all' %}active{% endif %}">Alle</a> | ||||
|                 <a href="?kind=series&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}" | ||||
|                     class="{% if kind == 'series' %}active{% endif %}">Serien</a> | ||||
|                 <a href="?kind=movies&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}" | ||||
|                     class="{% if kind == 'movies' %}active{% endif %}">Filme</a> | ||||
|                 {% endwith %} | ||||
|             </nav> | ||||
|         </div> | ||||
|  | ||||
|         {% if show_series %} | ||||
|         <div class="section"> | ||||
|             <h2>Laufende Serien</h2> | ||||
|             <div class="grid"> | ||||
|                 {% for s in series_grouped %} | ||||
|                 <div class="card" data-series-id="{{ s.seriesId }}" data-title="{{ s.seriesTitle|escape }}" | ||||
|                     data-poster="{{ s.seriesPoster|default:'' }}" | ||||
|                     data-overview="{{ s.seriesOverview|default:''|escape }}"> | ||||
|                     <div class="poster"> | ||||
|                         {% if s.seriesPoster %} | ||||
|                         <img src="{{ s.seriesPoster }}" alt="{{ s.seriesTitle }}"> | ||||
|                         {% else %} | ||||
|                         <img src="https://via.placeholder.com/110x165?text=No+Poster" alt=""> | ||||
|                         {% endif %} | ||||
|                     </div> | ||||
|                     <div class="meta"> | ||||
|                         <div class="title" title="{{ s.seriesTitle }}">{{ s.seriesTitle }}</div> | ||||
|                         <div class="episodes"> | ||||
|                             {% for e in s.episodes %} | ||||
|                             <div class="ep"> | ||||
|                                 S{{ e.seasonNumber }}E{{ e.episodeNumber }} — {{ e.title|default:"(tba)" }}<br> | ||||
|                                 <span class="muted" data-dt="{{ e.airDateUtc|default:'' }}"></span> | ||||
|                             </div> | ||||
|                             {% empty %} | ||||
|                             <div class="muted">Keine kommenden Episoden.</div> | ||||
|                             {% endfor %} | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     {# sichere Episoden-JSON für Modal #} | ||||
|                     {% with sid=s.seriesId|stringformat:"s" %} | ||||
|                     {% with eid="eps-"|add:sid %} | ||||
|                     {{ s.episodes|json_script:eid }} | ||||
|                     {% endwith %} | ||||
|                     {% endwith %} | ||||
|                 </div> | ||||
|                 {% empty %} | ||||
|                 <p class="muted">Keine Serien gefunden.</p> | ||||
|                 {% endfor %} | ||||
|             </div> | ||||
|         </div> | ||||
|         {% endif %} | ||||
|  | ||||
|         {% if show_movies %} | ||||
|         <div class="section"> | ||||
|             <h2>Anstehende Filme</h2> | ||||
|             <div class="grid"> | ||||
|                 {% for m in movies %} | ||||
|                 <div class="movie-card" data-kind="movie" data-title="{{ m.title|escape }}" | ||||
|                     data-poster="{{ m.posterUrl|default:'' }}" data-overview="{{ m.overview|default:''|escape }}"> | ||||
|                     {% if m.posterUrl %} | ||||
|                     <img src="{{ m.posterUrl }}" alt="{{ m.title }}"> | ||||
|                     {% else %} | ||||
|                     <img src="https://via.placeholder.com/300x450?text=No+Poster" alt=""> | ||||
|                     {% endif %} | ||||
|                     <div class="title">{{ m.title }}{% if m.year %} ({{ m.year }}){% endif %}</div> | ||||
|                     <div class="muted"> | ||||
|                         {% if m.inCinemas %}Kino: <span data-dt="{{ m.inCinemas }}"></span>{% endif %} | ||||
|                         {% if m.digitalRelease %}<br>Digital: <span data-dt="{{ m.digitalRelease }}"></span>{% endif %} | ||||
|                         {% if m.physicalRelease %}<br>Disc: <span data-dt="{{ m.physicalRelease }}"></span>{% endif %} | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 {% empty %} | ||||
|                 <p class="muted">Keine Filme gefunden.</p> | ||||
|                 {% endfor %} | ||||
|             </div> | ||||
|         </div> | ||||
|         {% endif %} | ||||
|     </div> | ||||
|  | ||||
|     <!-- Modal --> | ||||
|     <div id="modalBackdrop" class="modal-backdrop" role="dialog" aria-modal="true" aria-hidden="true"> | ||||
|         <div class="modal"> | ||||
|             <div class="modal-header"> | ||||
|                 <div class="m-poster-wrap"> | ||||
|                     <div class="m-poster"><img id="mPoster" alt=""></div> | ||||
|                     <button id="subscribeBtn" class="btn-subscribe" type="button">Subscribe</button> | ||||
|                 </div> | ||||
|                 <div> | ||||
|                     <div id="mTitle" class="m-title"></div> | ||||
|                     <div id="mSub" class="m-sub"></div> | ||||
|                     <div id="mBadges" class="badges"></div> | ||||
|                 </div> | ||||
|                 <button class="modal-close" title="Schließen" aria-label="Schließen">×</button> | ||||
|             </div> | ||||
|  | ||||
|             <div class="modal-body"> | ||||
|                 <div class="section-block"> | ||||
|                     <div class="section-title">Beschreibung</div> | ||||
|                     <div id="mOverview" class="desc muted"></div> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="section-block"> | ||||
|                     <div class="section-title">Kommende Episoden</div> | ||||
|                     <div class="section-divider"></div> | ||||
|                     <div id="mEpisodes"></div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <script> | ||||
|         (function () { | ||||
|             // ===== Helpers ===== | ||||
|             const $ = (sel, root = document) => root.querySelector(sel); | ||||
|             const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); | ||||
|  | ||||
|             // ===== Modal-Elemente ===== | ||||
|             const backdrop = $("#modalBackdrop"); | ||||
|             const closeBtn = backdrop.querySelector(".modal-close"); | ||||
|             const mPoster = $("#mPoster"); | ||||
|             const mTitle = $("#mTitle"); | ||||
|             const mOverview = $("#mOverview"); | ||||
|             const mEpisodes = $("#mEpisodes"); | ||||
|             const mBadges = $("#mBadges"); | ||||
|             const mSub = $("#mSub"); | ||||
|             const epSection = mEpisodes.closest(".section-block"); | ||||
|             const subscribeBtn = $("#subscribeBtn"); | ||||
|  | ||||
|             let lastClickedCard = null; | ||||
|  | ||||
|             // ===== Modal open/close ===== | ||||
|             function openModal() { | ||||
|                 backdrop.style.display = "flex"; | ||||
|                 backdrop.setAttribute("aria-hidden", "false"); | ||||
|                 document.body.style.overflow = "hidden"; | ||||
|             } | ||||
|             function closeModal() { | ||||
|                 backdrop.style.display = "none"; | ||||
|                 backdrop.setAttribute("aria-hidden", "true"); | ||||
|                 document.body.style.overflow = ""; | ||||
|             } | ||||
|             closeBtn.addEventListener("click", closeModal); | ||||
|             backdrop.addEventListener("click", e => { if (e.target === backdrop) closeModal(); }); | ||||
|             window.addEventListener("keydown", e => { if (e.key === "Escape") closeModal(); }); | ||||
|  | ||||
|             // ===== Subscribe-Only-UI (mit localStorage) ===== | ||||
|             function subKey(card) { | ||||
|                 if (!card) return null; | ||||
|                 if (card.classList.contains("card") && card.dataset.seriesId) return "series:" + card.dataset.seriesId; | ||||
|                 return "movie:" + (card.dataset.title || ""); | ||||
|             } | ||||
|             function loadSub(card) { | ||||
|                 const k = subKey(card); | ||||
|                 return k ? localStorage.getItem("sub:" + k) === "1" : false; | ||||
|             } | ||||
|             function saveSub(card, on) { | ||||
|                 const k = subKey(card); | ||||
|                 if (!k) return; | ||||
|                 if (on) localStorage.setItem("sub:" + k, "1"); | ||||
|                 else localStorage.removeItem("sub:" + k); | ||||
|             } | ||||
|             function applySubUI(card, on) { | ||||
|                 if (card) card.classList.toggle("subscribed", !!on); // grüne Outline via .subscribed (CSS) | ||||
|                 if (subscribeBtn) { | ||||
|                     subscribeBtn.textContent = on ? "Unsubscribe" : "Subscribe"; | ||||
|                     subscribeBtn.setAttribute("aria-pressed", on ? "true" : "false"); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Beim Laden: gespeicherten Zustand auf alle Karten anwenden | ||||
|             $$(".card, .movie-card").forEach(c => applySubUI(c, loadSub(c))); | ||||
|  | ||||
|             // ===== Serien-Karten öffnen ===== | ||||
|             $$(".card").forEach(card => { | ||||
|                 card.addEventListener("click", () => { | ||||
|                     lastClickedCard = card; | ||||
|  | ||||
|                     const id = card.dataset.seriesId; | ||||
|                     const title = card.dataset.title || ""; | ||||
|                     const poster = card.dataset.poster || ""; | ||||
|                     const overview = card.dataset.overview || ""; | ||||
|  | ||||
|                     // Episoden aus eingebettetem JSON <script id="eps-<id>"> | ||||
|                     let episodes = []; | ||||
|                     const script = document.getElementById("eps-" + id); | ||||
|                     if (script) { try { episodes = JSON.parse(script.textContent); } catch { } } | ||||
|  | ||||
|                     // Modal befüllen | ||||
|                     mTitle.textContent = title; | ||||
|                     mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster"; | ||||
|                     mPoster.alt = title; | ||||
|                     mOverview.textContent = overview || "Keine Beschreibung verfügbar."; | ||||
|  | ||||
|                     mSub.textContent = episodes.length | ||||
|                         ? `${episodes.length} kommende Episode(n)` | ||||
|                         : "Keine kommenden Episoden"; | ||||
|  | ||||
|                     // Genres-Badges, falls data-genres vorhanden | ||||
|                     mBadges.innerHTML = ""; | ||||
|                     if (card.dataset.genres) { | ||||
|                         card.dataset.genres.split(",").map(s => s.trim()).filter(Boolean).forEach(g => { | ||||
|                             const b = document.createElement("span"); | ||||
|                             b.className = "badge"; | ||||
|                             b.textContent = g; | ||||
|                             mBadges.appendChild(b); | ||||
|                         }); | ||||
|                     } | ||||
|  | ||||
|                     // Episodenbereich | ||||
|                     epSection.style.display = ""; | ||||
|                     mEpisodes.innerHTML = ""; | ||||
|                     if (!episodes.length) { | ||||
|                         const p = document.createElement("p"); | ||||
|                         p.className = "muted"; | ||||
|                         p.textContent = "—"; | ||||
|                         mEpisodes.appendChild(p); | ||||
|                     } else { | ||||
|                         episodes.forEach(e => { | ||||
|                             const row = document.createElement("div"); | ||||
|                             row.className = "ep-row"; | ||||
|                             const dt = e.airDateUtc ? new Date(e.airDateUtc) : null; | ||||
|                             const when = dt && !isNaN(dt) ? dt.toLocaleString() : "-"; | ||||
|                             row.innerHTML = `<strong>S${e.seasonNumber}E${e.episodeNumber}</strong> — ${e.title ?? "(tba)"}<br><span class="muted">${when}</span>`; | ||||
|                             mEpisodes.appendChild(row); | ||||
|                         }); | ||||
|                     } | ||||
|  | ||||
|                     // Subscribe-UI für diese Karte setzen | ||||
|                     applySubUI(lastClickedCard, loadSub(lastClickedCard)); | ||||
|  | ||||
|                     openModal(); | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             // ===== Film-Karten öffnen ===== | ||||
|             $$(".movie-card").forEach(card => { | ||||
|                 card.addEventListener("click", () => { | ||||
|                     lastClickedCard = card; | ||||
|  | ||||
|                     const title = card.dataset.title || ""; | ||||
|                     const poster = card.dataset.poster || ""; | ||||
|                     const overview = card.dataset.overview || ""; | ||||
|  | ||||
|                     mTitle.textContent = title; | ||||
|                     mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster"; | ||||
|                     mPoster.alt = title; | ||||
|                     mOverview.textContent = overview || "Keine Beschreibung verfügbar."; | ||||
|  | ||||
|                     mSub.textContent = ""; | ||||
|                     mBadges.innerHTML = ""; | ||||
|  | ||||
|                     // Episodenbereich ausblenden | ||||
|                     epSection.style.display = "none"; | ||||
|                     mEpisodes.innerHTML = ""; | ||||
|  | ||||
|                     // Subscribe-UI für diese Karte setzen | ||||
|                     applySubUI(lastClickedCard, loadSub(lastClickedCard)); | ||||
|  | ||||
|                     openModal(); | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             // ===== Subscribe-Button im Modal toggelt nur UI + localStorage ===== | ||||
|             if (subscribeBtn) { | ||||
|                 subscribeBtn.addEventListener("click", () => { | ||||
|                     if (!lastClickedCard) return; | ||||
|                     const now = !loadSub(lastClickedCard); | ||||
|                     saveSub(lastClickedCard, now); | ||||
|                     applySubUI(lastClickedCard, now); | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             // ===== Datumsangaben in der Übersicht formatieren ===== | ||||
|             document.querySelectorAll("[data-dt]").forEach(el => { | ||||
|                 const v = el.getAttribute("data-dt"); | ||||
|                 if (!v) return; | ||||
|                 const d = new Date(v); | ||||
|                 el.textContent = isNaN(d) ? v : d.toLocaleString(); | ||||
|             }); | ||||
|         })(); | ||||
|     </script> | ||||
|  | ||||
| </body> | ||||
|  | ||||
| </html> | ||||
							
								
								
									
										3
									
								
								arr_api/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								arr_api/tests.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| from django.test import TestCase | ||||
|  | ||||
| # Create your tests here. | ||||
							
								
								
									
										7
									
								
								arr_api/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								arr_api/urls.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| from django.urls import path | ||||
| from .views import SonarrAiringView, RadarrUpcomingMoviesView | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("sonarr/airing", SonarrAiringView.as_view(), name="sonarr-airing"), | ||||
|     path("radarr/upcoming", RadarrUpcomingMoviesView.as_view(), name="radarr-upcoming"), | ||||
| ] | ||||
							
								
								
									
										114
									
								
								arr_api/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								arr_api/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,114 @@ | ||||
| from collections import defaultdict | ||||
| from django.shortcuts import render | ||||
| from django.views import View | ||||
| from django.contrib import messages | ||||
| from rest_framework.views import APIView | ||||
| from rest_framework.response import Response | ||||
| from rest_framework import status | ||||
|  | ||||
| from settingspanel.models import AppSettings | ||||
| from .services import sonarr_calendar, radarr_calendar, ArrServiceError | ||||
|  | ||||
|  | ||||
| def _get_int(request, key, default): | ||||
|     try: | ||||
|         v = int(request.GET.get(key, default)) | ||||
|         return max(1, min(365, v)) | ||||
|     except (TypeError, ValueError): | ||||
|         return default | ||||
|  | ||||
| def _arr_conf_from_db(): | ||||
|     cfg = AppSettings.current() | ||||
|     return { | ||||
|         "sonarr_url": cfg.sonarr_url, | ||||
|         "sonarr_key": cfg.sonarr_api_key, | ||||
|         "radarr_url": cfg.radarr_url, | ||||
|         "radarr_key": cfg.radarr_api_key, | ||||
|     } | ||||
|  | ||||
|  | ||||
| class SonarrAiringView(APIView): | ||||
|     def get(self, request): | ||||
|         days = _get_int(request, "days", 30) | ||||
|         conf = _arr_conf_from_db() | ||||
|         try: | ||||
|             data = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"]) | ||||
|             return Response({"count": len(data), "results": data}) | ||||
|         except ArrServiceError as e: | ||||
|             return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY) | ||||
|  | ||||
|  | ||||
| class RadarrUpcomingMoviesView(APIView): | ||||
|     def get(self, request): | ||||
|         days = _get_int(request, "days", 60) | ||||
|         conf = _arr_conf_from_db() | ||||
|         try: | ||||
|             data = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"]) | ||||
|             return Response({"count": len(data), "results": data}) | ||||
|         except ArrServiceError as e: | ||||
|             return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY) | ||||
|  | ||||
|  | ||||
| class ArrIndexView(View): | ||||
|     def get(self, request): | ||||
|         q = (request.GET.get("q") or "").lower().strip() | ||||
|         kind = (request.GET.get("kind") or "all").lower() | ||||
|         days = _get_int(request, "days", 30) | ||||
|  | ||||
|         conf = _arr_conf_from_db() | ||||
|  | ||||
|         eps, movies = [], [] | ||||
|         # Sonarr robust laden | ||||
|         try: | ||||
|             eps = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"]) | ||||
|         except ArrServiceError as e: | ||||
|             messages.error(request, f"Sonarr nicht erreichbar: {e}") | ||||
|  | ||||
|         # Radarr robust laden | ||||
|         try: | ||||
|             movies = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"]) | ||||
|         except ArrServiceError as e: | ||||
|             messages.error(request, f"Radarr nicht erreichbar: {e}") | ||||
|  | ||||
|         # Suche | ||||
|         if q: | ||||
|             eps = [e for e in eps if q in (e.get("seriesTitle") or "").lower()] | ||||
|             movies = [m for m in movies if q in (m.get("title") or "").lower()] | ||||
|  | ||||
|         # Gruppierung nach Serie | ||||
|         groups = defaultdict(lambda: { | ||||
|             "seriesId": None, "seriesTitle": None, "seriesPoster": None, | ||||
|             "seriesOverview": "", "seriesGenres": [], "episodes": [], | ||||
|         }) | ||||
|         for e in eps: | ||||
|             sid = e["seriesId"] | ||||
|             g = groups[sid] | ||||
|             g["seriesId"] = sid | ||||
|             g["seriesTitle"] = e["seriesTitle"] | ||||
|             g["seriesPoster"] = g["seriesPoster"] or e.get("seriesPoster") | ||||
|             if not g["seriesOverview"] and e.get("seriesOverview"): | ||||
|                 g["seriesOverview"] = e["seriesOverview"] | ||||
|             if not g["seriesGenres"] and e.get("seriesGenres"): | ||||
|                 g["seriesGenres"] = e["seriesGenres"] | ||||
|             g["episodes"].append({ | ||||
|                 "episodeId": e["episodeId"], | ||||
|                 "seasonNumber": e["seasonNumber"], | ||||
|                 "episodeNumber": e["episodeNumber"], | ||||
|                 "title": e["title"], | ||||
|                 "airDateUtc": e["airDateUtc"], | ||||
|             }) | ||||
|  | ||||
|         series_grouped = [] | ||||
|         for g in groups.values(): | ||||
|             g["episodes"].sort(key=lambda x: (x["airDateUtc"] or "")) | ||||
|             series_grouped.append(g) | ||||
|  | ||||
|         return render(request, "arr_api/index.html", { | ||||
|             "query": q, | ||||
|             "kind": kind, | ||||
|             "days": days, | ||||
|             "show_series": kind in ("all", "series"), | ||||
|             "show_movies": kind in ("all", "movies"), | ||||
|             "series_grouped": series_grouped, | ||||
|             "movies": movies, | ||||
|         }) | ||||
							
								
								
									
										4
									
								
								cookies.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								cookies.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # Netscape HTTP Cookie File | ||||
| # https://curl.se/docs/http-cookies.html | ||||
| # This file was generated by libcurl! Edit at your own risk. | ||||
|  | ||||
							
								
								
									
										22
									
								
								manage.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										22
									
								
								manage.py
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| #!/usr/bin/env python | ||||
| """Django's command-line utility for administrative tasks.""" | ||||
| import os | ||||
| import sys | ||||
|  | ||||
|  | ||||
| def main(): | ||||
|     """Run administrative tasks.""" | ||||
|     os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'subscribarr.settings') | ||||
|     try: | ||||
|         from django.core.management import execute_from_command_line | ||||
|     except ImportError as exc: | ||||
|         raise ImportError( | ||||
|             "Couldn't import Django. Are you sure it's installed and " | ||||
|             "available on your PYTHONPATH environment variable? Did you " | ||||
|             "forget to activate a virtual environment?" | ||||
|         ) from exc | ||||
|     execute_from_command_line(sys.argv) | ||||
|  | ||||
|  | ||||
| if __name__ == '__main__': | ||||
|     main() | ||||
							
								
								
									
										0
									
								
								settingspanel/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								settingspanel/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										3
									
								
								settingspanel/admin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								settingspanel/admin.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| from django.contrib import admin | ||||
|  | ||||
| # Register your models here. | ||||
							
								
								
									
										6
									
								
								settingspanel/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								settingspanel/apps.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| from django.apps import AppConfig | ||||
|  | ||||
|  | ||||
| class SettingspanelConfig(AppConfig): | ||||
|     default_auto_field = 'django.db.models.BigAutoField' | ||||
|     name = 'settingspanel' | ||||
							
								
								
									
										33
									
								
								settingspanel/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								settingspanel/forms.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| from django import forms | ||||
|  | ||||
| WIDE = {"class": "input-wide"} | ||||
|  | ||||
| class ArrSettingsForm(forms.Form): | ||||
|     sonarr_url     = forms.URLField(label="Sonarr URL", required=False, | ||||
|                                     widget=forms.URLInput(attrs=WIDE)) | ||||
|     sonarr_api_key = forms.CharField(label="Sonarr API Key", required=False, | ||||
|                                     widget=forms.PasswordInput(render_value=True, attrs=WIDE)) | ||||
|     radarr_url     = forms.URLField(label="Radarr URL", required=False, | ||||
|                                     widget=forms.URLInput(attrs=WIDE)) | ||||
|     radarr_api_key = forms.CharField(label="Radarr API Key", required=False, | ||||
|                                     widget=forms.PasswordInput(render_value=True, attrs=WIDE)) | ||||
|  | ||||
| class MailSettingsForm(forms.Form): | ||||
|     mail_host = forms.CharField(label="Mail Host", required=False) | ||||
|     mail_port = forms.IntegerField(label="Mail Port", required=False, min_value=1, max_value=65535) | ||||
|     mail_secure = forms.ChoiceField( | ||||
|         label="Sicherheit", required=False, | ||||
|         choices=[("", "Kein TLS/SSL"), ("starttls", "STARTTLS"), ("ssl", "SSL/TLS")] | ||||
|     ) | ||||
|     mail_user = forms.CharField(label="Mail Benutzer", required=False) | ||||
|     mail_password = forms.CharField( | ||||
|         label="Mail Passwort", required=False, | ||||
|         widget=forms.PasswordInput(render_value=True) | ||||
|     ) | ||||
|     mail_from = forms.EmailField(label="Absender (From)", required=False) | ||||
|  | ||||
| class AccountForm(forms.Form): | ||||
|     username = forms.CharField(label="Benutzername", required=False) | ||||
|     email = forms.EmailField(label="E-Mail", required=False) | ||||
|     new_password = forms.CharField(label="Neues Passwort", required=False, widget=forms.PasswordInput) | ||||
|     repeat_password = forms.CharField(label="Passwort wiederholen", required=False, widget=forms.PasswordInput) | ||||
							
								
								
									
										34
									
								
								settingspanel/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								settingspanel/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| # Generated by Django 5.2.5 on 2025-08-08 23:24 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     initial = True | ||||
|  | ||||
|     dependencies = [ | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='AppSettings', | ||||
|             fields=[ | ||||
|                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('singleton_id', models.PositiveSmallIntegerField(default=1, editable=False, unique=True)), | ||||
|                 ('sonarr_url', models.URLField(blank=True, null=True)), | ||||
|                 ('sonarr_api_key', models.CharField(blank=True, max_length=255, null=True)), | ||||
|                 ('radarr_url', models.URLField(blank=True, null=True)), | ||||
|                 ('radarr_api_key', models.CharField(blank=True, max_length=255, null=True)), | ||||
|                 ('mail_host', models.CharField(blank=True, max_length=255, null=True)), | ||||
|                 ('mail_port', models.PositiveIntegerField(blank=True, null=True)), | ||||
|                 ('mail_secure', models.CharField(blank=True, choices=[('', 'Kein TLS/SSL'), ('starttls', 'STARTTLS'), ('ssl', 'SSL/TLS')], max_length=10, null=True)), | ||||
|                 ('mail_user', models.CharField(blank=True, max_length=255, null=True)), | ||||
|                 ('mail_password', models.CharField(blank=True, max_length=255, null=True)), | ||||
|                 ('mail_from', models.EmailField(blank=True, max_length=254, null=True)), | ||||
|                 ('acc_username', models.CharField(blank=True, max_length=150, null=True)), | ||||
|                 ('acc_email', models.EmailField(blank=True, max_length=254, null=True)), | ||||
|                 ('updated_at', models.DateTimeField(auto_now=True)), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										0
									
								
								settingspanel/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								settingspanel/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										36
									
								
								settingspanel/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								settingspanel/models.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| from django.db import models | ||||
|  | ||||
| class AppSettings(models.Model): | ||||
|     # Singleton-Pattern über feste ID | ||||
|     singleton_id = models.PositiveSmallIntegerField(default=1, unique=True, editable=False) | ||||
|  | ||||
|     # Arr | ||||
|     sonarr_url = models.URLField(blank=True, null=True) | ||||
|     sonarr_api_key = models.CharField(max_length=255, blank=True, null=True) | ||||
|     radarr_url = models.URLField(blank=True, null=True) | ||||
|     radarr_api_key = models.CharField(max_length=255, blank=True, null=True) | ||||
|  | ||||
|     # Mail | ||||
|     mail_host = models.CharField(max_length=255, blank=True, null=True) | ||||
|     mail_port = models.PositiveIntegerField(blank=True, null=True) | ||||
|     mail_secure = models.CharField( | ||||
|         max_length=10, blank=True, null=True, | ||||
|         choices=(("", "Kein TLS/SSL"), ("starttls", "STARTTLS"), ("ssl", "SSL/TLS")) | ||||
|     ) | ||||
|     mail_user = models.CharField(max_length=255, blank=True, null=True) | ||||
|     mail_password = models.CharField(max_length=255, blank=True, null=True) | ||||
|     mail_from = models.EmailField(blank=True, null=True) | ||||
|  | ||||
|     # „Account“ | ||||
|     acc_username = models.CharField(max_length=150, blank=True, null=True) | ||||
|     acc_email = models.EmailField(blank=True, null=True) | ||||
|  | ||||
|     updated_at = models.DateTimeField(auto_now=True) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return "AppSettings" | ||||
|  | ||||
|     @classmethod | ||||
|     def current(cls): | ||||
|         obj, _ = cls.objects.get_or_create(singleton_id=1) | ||||
|         return obj | ||||
							
								
								
									
										350
									
								
								settingspanel/templates/settingspanel/settings.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										350
									
								
								settingspanel/templates/settingspanel/settings.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,350 @@ | ||||
| {% load static %} | ||||
| <!doctype html> | ||||
| <html lang="de"> | ||||
|  | ||||
| <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
|     <title>Einstellungen – Subscribarr</title> | ||||
|     <style> | ||||
|         :root { | ||||
|             --bg: #0b0b10; | ||||
|             --panel: #12121a; | ||||
|             --panel-b: #1f2030; | ||||
|             --accent: #3b82f6; | ||||
|             --muted: #9aa0b4; | ||||
|             --text: #e6e6e6; | ||||
|         } | ||||
|  | ||||
|         * { | ||||
|             box-sizing: border-box | ||||
|         } | ||||
|  | ||||
|         body { | ||||
|             background: var(--bg); | ||||
|             color: var(--text); | ||||
|             font-family: system-ui, Segoe UI, Roboto, Arial, sans-serif; | ||||
|             margin: 0 | ||||
|         } | ||||
|  | ||||
|         .wrap { | ||||
|             max-width: 1000px; | ||||
|             margin: 0 auto; | ||||
|             padding: 16px | ||||
|         } | ||||
|  | ||||
|         a { | ||||
|             color: #cfd3ea; | ||||
|             text-decoration: none | ||||
|         } | ||||
|  | ||||
|         .topbar { | ||||
|             display: flex; | ||||
|             align-items: center; | ||||
|             justify-content: space-between; | ||||
|             margin-bottom: 16px | ||||
|         } | ||||
|  | ||||
|         .btn { | ||||
|             padding: 10px 14px; | ||||
|             border-radius: 10px; | ||||
|             border: 1px solid #2a2a34; | ||||
|             background: #111119; | ||||
|             color: #fff; | ||||
|             cursor: pointer | ||||
|         } | ||||
|  | ||||
|         .btn-primary { | ||||
|             background: var(--accent); | ||||
|             border-color: transparent | ||||
|         } | ||||
|  | ||||
|         .grid { | ||||
|             display: grid; | ||||
|             gap: 16px | ||||
|         } | ||||
|  | ||||
|         @media(min-width:900px) { | ||||
|             .grid { | ||||
|                 grid-template-columns: 1fr 1fr | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         .card { | ||||
|             background: var(--panel); | ||||
|             border: 1px solid var(--panel-b); | ||||
|             border-radius: 12px; | ||||
|             padding: 14px | ||||
|         } | ||||
|  | ||||
|         .card h2 { | ||||
|             margin: 0 0 10px; | ||||
|             font-size: 1.05rem | ||||
|         } | ||||
|  | ||||
|         .row { | ||||
|             display: grid; | ||||
|             grid-template-columns: 160px minmax(0, 1fr); | ||||
|             /* <= statt 160px 1fr */ | ||||
|             gap: 10px; | ||||
|             align-items: center; | ||||
|             margin-bottom: 10px; | ||||
|         } | ||||
|  | ||||
|         .row label { | ||||
|             color: #c9cbe3 | ||||
|         } | ||||
|  | ||||
|         .row input, | ||||
|         .row select { | ||||
|             width: 100%; | ||||
|             padding: 10px 12px; | ||||
|             border-radius: 10px; | ||||
|             border: 1px solid #2a2a34; | ||||
|             background: #111119; | ||||
|             color: var(--text) | ||||
|         } | ||||
|  | ||||
|         .help { | ||||
|             color: var(--muted); | ||||
|             font-size: .9rem | ||||
|         } | ||||
|  | ||||
|         .msgs { | ||||
|             margin-bottom: 10px | ||||
|         } | ||||
|  | ||||
|         .msg { | ||||
|             background: #0f1425; | ||||
|             border: 1px solid #283058; | ||||
|             border-radius: 10px; | ||||
|             padding: 10px; | ||||
|             margin-bottom: 8px | ||||
|         } | ||||
|  | ||||
|         .input-wide { | ||||
|             width: 100% !important; | ||||
|             max-width: 100%; | ||||
|             min-width: 0; | ||||
|             display: block; | ||||
|         } | ||||
|  | ||||
|         .row input, | ||||
|         .row select, | ||||
|         .row textarea { | ||||
|             width: 100%; | ||||
|             max-width: 100%; | ||||
|             min-width: 0; | ||||
|             display: block; | ||||
|         } | ||||
|  | ||||
|         /* falls du passwort/URL Felder extra stylen willst, gleicher Fix */ | ||||
|         .inline>input, | ||||
|         .inline>.django-url, | ||||
|         /* falls Widget eine Klasse rendert */ | ||||
|         .inline>.django-password { | ||||
|             min-width: 0; | ||||
|             width: 100%; | ||||
|         } | ||||
|  | ||||
|         .inline-actions { | ||||
|             display: inline-flex; | ||||
|             align-items: center; | ||||
|             gap: 8px; | ||||
|             min-width: 220px; | ||||
|             justify-content: flex-end; | ||||
|         } | ||||
|  | ||||
|         .btn { | ||||
|             padding: 10px 14px; | ||||
|             border-radius: 10px; | ||||
|             border: 1px solid #2a2a34; | ||||
|             background: #111119; | ||||
|             color: #fff; | ||||
|             cursor: pointer; | ||||
|         } | ||||
|  | ||||
|         .btn:disabled { | ||||
|             opacity: .6; | ||||
|             cursor: default; | ||||
|         } | ||||
|  | ||||
|         .badge { | ||||
|             padding: 6px 10px; | ||||
|             border-radius: 999px; | ||||
|             font-size: .85rem; | ||||
|             border: 1px solid #2a2a34; | ||||
|             background: #111119; | ||||
|             color: #cfd3ea; | ||||
|             white-space: nowrap; | ||||
|             /* eine Zeile */ | ||||
|             max-width: 140px; | ||||
|             /* begrenzt die Breite */ | ||||
|             overflow: hidden; | ||||
|             text-overflow: ellipsis; | ||||
|             /* falls doch Text drin ist */ | ||||
|         } | ||||
|  | ||||
|         .badge.ok { | ||||
|             border-color: #1f6f3a; | ||||
|             background: #10331f; | ||||
|             color: #a7e3bd; | ||||
|         } | ||||
|  | ||||
|         .badge.err { | ||||
|             border-color: #6f1f2a; | ||||
|             background: #341016; | ||||
|             color: #f1a3b0; | ||||
|         } | ||||
|  | ||||
|         .badge.muted { | ||||
|             opacity: .8; | ||||
|         } | ||||
|     </style> | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
|     <div class="wrap"> | ||||
|         <div class="topbar"> | ||||
|             <div><a href="/" class="btn">← Zurück</a></div> | ||||
|             <div><strong>Einstellungen</strong></div> | ||||
|             <div></div> | ||||
|         </div> | ||||
|  | ||||
|         {% if messages %} | ||||
|         <div class="msgs">{% for m in messages %}<div class="msg">{{ m }}</div>{% endfor %}</div> | ||||
|         {% endif %} | ||||
|  | ||||
|         <form method="post"> | ||||
|             {% csrf_token %} | ||||
|             <div class="grid"> | ||||
|                 <div class="card"> | ||||
|                     <h2>Sonarr & Radarr</h2> | ||||
|  | ||||
|                     <div class="row"> | ||||
|                         <label>Sonarr URL</label> | ||||
|                         <div class="inline"> | ||||
|                             <div class="field">{{ arr_form.sonarr_url }}</div> | ||||
|                             <div class="inline-actions"> | ||||
|                                 <button class="btn" type="button" onclick="testConnection('sonarr', this)">Test | ||||
|                                     Sonarr</button> | ||||
|                                 <span id="sonarrStatus" class="badge muted">—</span> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="row"> | ||||
|                         <label>Sonarr API Key</label> | ||||
|                         {{ arr_form.sonarr_api_key }} | ||||
|                     </div> | ||||
|  | ||||
|  | ||||
|                     <div class="row"> | ||||
|                         <label>Radarr URL</label> | ||||
|                         <div class="inline"> | ||||
|                             <div class="field">{{ arr_form.radarr_url }}</div> | ||||
|                             <div class="inline-actions"> | ||||
|                                 <button class="btn" type="button" onclick="testConnection('radarr', this)">Test | ||||
|                                     Radarr</button> | ||||
|                                 <span id="radarrStatus" class="badge muted">—</span> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="row"> | ||||
|                         <label>Radarr API Key</label> | ||||
|                         {{ arr_form.radarr_api_key }} | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="help">Klicke „Test …“, um die Verbindung gegen <code>/api/v3/system/status</code> zu | ||||
|                         prüfen.</div> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="card"> | ||||
|                     <h2>Mailserver</h2> | ||||
|                     <div class="row"><label>Host</label>{{ mail_form.mail_host }}</div> | ||||
|                     <div class="row"><label>Port</label>{{ mail_form.mail_port }}</div> | ||||
|                     <div class="row"><label>Sicherheit</label>{{ mail_form.mail_secure }}</div> | ||||
|                     <div class="row"><label>Benutzer</label>{{ mail_form.mail_user }}</div> | ||||
|                     <div class="row"><label>Passwort</label>{{ mail_form.mail_password }}</div> | ||||
|                     <div class="row"><label>Absender</label>{{ mail_form.mail_from }}</div> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="card"> | ||||
|                     <h2>Konto</h2> | ||||
|                     <div class="row"><label>Benutzername</label>{{ account_form.username }}</div> | ||||
|                     <div class="row"><label>E-Mail</label>{{ account_form.email }}</div> | ||||
|                     <div class="row"><label>Neues Passwort</label>{{ account_form.new_password }}</div> | ||||
|                     <div class="row"><label>Passwort wiederholen</label>{{ account_form.repeat_password }}</div> | ||||
|                     <div class="help">Nur Oberfläche – Umsetzung Passwortänderung später.</div> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             <div style="margin-top:16px"> | ||||
|                 <button class="btn btn-primary" type="submit">Speichern</button> | ||||
|             </div> | ||||
|         </form> | ||||
|     </div> | ||||
|  | ||||
|     <script> | ||||
|  | ||||
|         function testConnection(kind) { | ||||
|             const url = document.querySelector(`input[name="${kind}_url"]`).value; | ||||
|             const key = document.querySelector(`input[name="${kind}_api_key"]`).value; | ||||
|  | ||||
|             fetch(`/settings/test-connection/?kind=${kind}&url=${encodeURIComponent(url)}&key=${encodeURIComponent(key)}`) | ||||
|                 .then(r => r.json()) | ||||
|                 .then(data => { | ||||
|                     if (data.ok) { | ||||
|                         alert(kind + " Verbindung erfolgreich!"); | ||||
|                     } else { | ||||
|                         alert(kind + " Fehler: " + data.error); | ||||
|                     } | ||||
|                 }) | ||||
|                 .catch(err => alert(kind + " Fehler: " + err)); | ||||
|         } | ||||
|  | ||||
|         function setBadge(kind, state, text, tooltip) { | ||||
|             const el = document.getElementById(kind + "Status"); | ||||
|             if (!el) return; | ||||
|             el.classList.remove("ok", "err", "muted"); | ||||
|             el.title = tooltip || "";               // voller Fehlertext im Tooltip | ||||
|             if (state === "ok") { | ||||
|                 el.classList.add("ok"); | ||||
|                 el.textContent = "Verbunden"; | ||||
|             } else if (state === "err") { | ||||
|                 el.classList.add("err"); | ||||
|                 el.textContent = "Fehler"; | ||||
|             } else { | ||||
|                 el.classList.add("muted"); | ||||
|                 el.textContent = "—"; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         function testConnection(kind, btnEl) { | ||||
|             const urlEl = document.querySelector(`input[name="${kind}_url"]`); | ||||
|             const keyEl = document.querySelector(`input[name="${kind}_api_key"]`); | ||||
|             const url = urlEl ? urlEl.value.trim() : ""; | ||||
|             const key = keyEl ? keyEl.value.trim() : ""; | ||||
|  | ||||
|             setBadge(kind, "muted", "Teste…"); | ||||
|             if (btnEl) { btnEl.disabled = true; } | ||||
|  | ||||
|             fetch(`/settings/test-connection/?kind=${encodeURIComponent(kind)}&url=${encodeURIComponent(url)}&key=${encodeURIComponent(key)}`) | ||||
|                 .then(r => r.json()) | ||||
|                 .then(data => { | ||||
|                     if (data.ok) { | ||||
|                         setBadge(kind, "ok", "Verbunden", ""); | ||||
|                     } else { | ||||
|                         setBadge(kind, "err", "Fehler", data.error || "Unbekannter Fehler"); | ||||
|                     } | ||||
|                 }) | ||||
|                 .catch(err => setBadge(kind, "err", "Fehler", String(err))) | ||||
|                 .finally(() => { if (btnEl) btnEl.disabled = false; }); | ||||
|         } | ||||
|  | ||||
|     </script> | ||||
|  | ||||
| </body> | ||||
|  | ||||
| </html> | ||||
							
								
								
									
										3
									
								
								settingspanel/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								settingspanel/tests.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| from django.test import TestCase | ||||
|  | ||||
| # Create your tests here. | ||||
							
								
								
									
										8
									
								
								settingspanel/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								settingspanel/urls.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| from django.urls import path | ||||
| from .views import SettingsView, test_connection | ||||
|  | ||||
| app_name = "settingspanel" | ||||
| urlpatterns = [ | ||||
|     path("", SettingsView.as_view(), name="index"), | ||||
|     path("test-connection/", test_connection, name="test_connection"), | ||||
| ] | ||||
							
								
								
									
										83
									
								
								settingspanel/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								settingspanel/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,83 @@ | ||||
| from django.views import View | ||||
| from django.shortcuts import render, redirect | ||||
| from django.contrib import messages | ||||
| from .forms import ArrSettingsForm, MailSettingsForm, AccountForm | ||||
| from .models import AppSettings | ||||
| from django.http import JsonResponse | ||||
| import requests | ||||
|  | ||||
| def test_connection(request): | ||||
|     kind = request.GET.get("kind")  # "sonarr" | "radarr" | ||||
|     url = (request.GET.get("url") or "").strip() | ||||
|     key = (request.GET.get("key") or "").strip() | ||||
|     if kind not in ("sonarr", "radarr"): | ||||
|         return JsonResponse({"ok": False, "error": "Ungültiger Typ"}, status=400) | ||||
|     if not url or not key: | ||||
|         return JsonResponse({"ok": False, "error": "URL und API-Key erforderlich"}, status=400) | ||||
|  | ||||
|     try: | ||||
|         r = requests.get( | ||||
|             f"{url.rstrip('/')}/api/v3/system/status", | ||||
|             headers={"X-Api-Key": key}, | ||||
|             timeout=5 | ||||
|         ) | ||||
|         if r.status_code == 200: | ||||
|             return JsonResponse({"ok": True}) | ||||
|         return JsonResponse({"ok": False, "error": f"HTTP {r.status_code}"}) | ||||
|     except requests.RequestException as e: | ||||
|         return JsonResponse({"ok": False, "error": str(e)}) | ||||
|  | ||||
| class SettingsView(View): | ||||
|     template_name = "settingspanel/settings.html" | ||||
|  | ||||
|     def get(self, request): | ||||
|         cfg = AppSettings.current() | ||||
|         return render(request, self.template_name, { | ||||
|             "arr_form": ArrSettingsForm(initial={ | ||||
|                 "sonarr_url": cfg.sonarr_url or "", | ||||
|                 "sonarr_api_key": cfg.sonarr_api_key or "", | ||||
|                 "radarr_url": cfg.radarr_url or "", | ||||
|                 "radarr_api_key": cfg.radarr_api_key or "", | ||||
|             }), | ||||
|             "mail_form": MailSettingsForm(initial={ | ||||
|                 "mail_host": cfg.mail_host or "", | ||||
|                 "mail_port": cfg.mail_port or "", | ||||
|                 "mail_secure": cfg.mail_secure or "", | ||||
|                 "mail_user": cfg.mail_user or "", | ||||
|                 "mail_password": cfg.mail_password or "", | ||||
|                 "mail_from": cfg.mail_from or "", | ||||
|             }), | ||||
|             "account_form": AccountForm(initial={ | ||||
|                 "username": cfg.acc_username or "", | ||||
|                 "email": cfg.acc_email or "", | ||||
|             }), | ||||
|         }) | ||||
|  | ||||
|     def post(self, request): | ||||
|         arr_form  = ArrSettingsForm(request.POST) | ||||
|         mail_form = MailSettingsForm(request.POST) | ||||
|         acc_form  = AccountForm(request.POST) | ||||
|         if not (arr_form.is_valid() and mail_form.is_valid() and acc_form.is_valid()): | ||||
|             return render(request, self.template_name, { | ||||
|                 "arr_form": arr_form, "mail_form": mail_form, "account_form": acc_form | ||||
|             }) | ||||
|  | ||||
|         cfg = AppSettings.current() | ||||
|         cfg.sonarr_url     = arr_form.cleaned_data["sonarr_url"] or None | ||||
|         cfg.sonarr_api_key = arr_form.cleaned_data["sonarr_api_key"] or None | ||||
|         cfg.radarr_url     = arr_form.cleaned_data["radarr_url"] or None | ||||
|         cfg.radarr_api_key = arr_form.cleaned_data["radarr_api_key"] or None | ||||
|  | ||||
|         cfg.mail_host     = mail_form.cleaned_data["mail_host"] or None | ||||
|         cfg.mail_port     = mail_form.cleaned_data["mail_port"] or None | ||||
|         cfg.mail_secure   = mail_form.cleaned_data["mail_secure"] or "" | ||||
|         cfg.mail_user     = mail_form.cleaned_data["mail_user"] or None | ||||
|         cfg.mail_password = mail_form.cleaned_data["mail_password"] or None | ||||
|         cfg.mail_from     = mail_form.cleaned_data["mail_from"] or None | ||||
|  | ||||
|         cfg.acc_username = acc_form.cleaned_data["username"] or None | ||||
|         cfg.acc_email    = acc_form.cleaned_data["email"] or None | ||||
|  | ||||
|         cfg.save() | ||||
|         messages.success(request, "Einstellungen gespeichert (DB).") | ||||
|         return redirect("settingspanel:index") | ||||
							
								
								
									
										0
									
								
								subscribarr/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								subscribarr/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										16
									
								
								subscribarr/asgi.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								subscribarr/asgi.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| """ | ||||
| ASGI config for subscribarr project. | ||||
|  | ||||
| It exposes the ASGI callable as a module-level variable named ``application``. | ||||
|  | ||||
| For more information on this file, see | ||||
| https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ | ||||
| """ | ||||
|  | ||||
| import os | ||||
|  | ||||
| from django.core.asgi import get_asgi_application | ||||
|  | ||||
| os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'subscribarr.settings') | ||||
|  | ||||
| application = get_asgi_application() | ||||
							
								
								
									
										125
									
								
								subscribarr/settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								subscribarr/settings.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | ||||
| """ | ||||
| Django settings for subscribarr project. | ||||
|  | ||||
| Generated by 'django-admin startproject' using Django 5.2.5. | ||||
|  | ||||
| For more information on this file, see | ||||
| https://docs.djangoproject.com/en/5.2/topics/settings/ | ||||
|  | ||||
| For the full list of settings and their values, see | ||||
| https://docs.djangoproject.com/en/5.2/ref/settings/ | ||||
| """ | ||||
|  | ||||
| from pathlib import Path | ||||
|  | ||||
| # Build paths inside the project like this: BASE_DIR / 'subdir'. | ||||
| BASE_DIR = Path(__file__).resolve().parent.parent | ||||
|  | ||||
|  | ||||
| # Quick-start development settings - unsuitable for production | ||||
| # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ | ||||
|  | ||||
| # SECURITY WARNING: keep the secret key used in production secret! | ||||
| SECRET_KEY = 'django-insecure-p^2cgzteh=p3ppya71l7+r3nuc=_w@2#fer4n7ckywt1$x%u&j' | ||||
|  | ||||
| # SECURITY WARNING: don't run with debug turned on in production! | ||||
| DEBUG = True | ||||
|  | ||||
| ALLOWED_HOSTS = [] | ||||
|  | ||||
|  | ||||
| # Application definition | ||||
|  | ||||
| INSTALLED_APPS = [ | ||||
|     'django.contrib.admin', | ||||
|     'django.contrib.auth', | ||||
|     'django.contrib.contenttypes', | ||||
|     'django.contrib.sessions', | ||||
|     'django.contrib.messages', | ||||
|     'django.contrib.staticfiles', | ||||
|     'rest_framework', | ||||
|     'arr_api', | ||||
|     'settingspanel', | ||||
| ] | ||||
|  | ||||
| MIDDLEWARE = [ | ||||
|     'django.middleware.security.SecurityMiddleware', | ||||
|     'django.contrib.sessions.middleware.SessionMiddleware', | ||||
|     'django.middleware.common.CommonMiddleware', | ||||
|     'django.middleware.csrf.CsrfViewMiddleware', | ||||
|     'django.contrib.auth.middleware.AuthenticationMiddleware', | ||||
|     'django.contrib.messages.middleware.MessageMiddleware', | ||||
|     'django.middleware.clickjacking.XFrameOptionsMiddleware', | ||||
| ] | ||||
|  | ||||
| ROOT_URLCONF = 'subscribarr.urls' | ||||
|  | ||||
| TEMPLATES = [ | ||||
|     { | ||||
|         'BACKEND': 'django.template.backends.django.DjangoTemplates', | ||||
|         'DIRS': [], | ||||
|         'APP_DIRS': True, | ||||
|         'OPTIONS': { | ||||
|             'context_processors': [ | ||||
|                 'django.template.context_processors.request', | ||||
|                 'django.contrib.auth.context_processors.auth', | ||||
|                 'django.contrib.messages.context_processors.messages', | ||||
|             ], | ||||
|         }, | ||||
|     }, | ||||
| ] | ||||
|  | ||||
| WSGI_APPLICATION = 'subscribarr.wsgi.application' | ||||
|  | ||||
|  | ||||
| # Database | ||||
| # https://docs.djangoproject.com/en/5.2/ref/settings/#databases | ||||
|  | ||||
| DATABASES = { | ||||
|     'default': { | ||||
|         'ENGINE': 'django.db.backends.sqlite3', | ||||
|         'NAME': BASE_DIR / 'db.sqlite3', | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| # Password validation | ||||
| # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators | ||||
|  | ||||
| AUTH_PASSWORD_VALIDATORS = [ | ||||
|     { | ||||
|         'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', | ||||
|     }, | ||||
|     { | ||||
|         'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', | ||||
|     }, | ||||
|     { | ||||
|         'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', | ||||
|     }, | ||||
|     { | ||||
|         'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', | ||||
|     }, | ||||
| ] | ||||
|  | ||||
|  | ||||
| # Internationalization | ||||
| # https://docs.djangoproject.com/en/5.2/topics/i18n/ | ||||
|  | ||||
| LANGUAGE_CODE = 'en-us' | ||||
|  | ||||
| TIME_ZONE = 'UTC' | ||||
|  | ||||
| USE_I18N = True | ||||
|  | ||||
| USE_TZ = True | ||||
|  | ||||
|  | ||||
| # Static files (CSS, JavaScript, Images) | ||||
| # https://docs.djangoproject.com/en/5.2/howto/static-files/ | ||||
|  | ||||
| STATIC_URL = 'static/' | ||||
|  | ||||
| # Default primary key field type | ||||
| # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field | ||||
|  | ||||
| DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' | ||||
							
								
								
									
										26
									
								
								subscribarr/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								subscribarr/urls.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| """ | ||||
| URL configuration for subscribarr project. | ||||
|  | ||||
| The `urlpatterns` list routes URLs to views. For more information please see: | ||||
|     https://docs.djangoproject.com/en/5.2/topics/http/urls/ | ||||
| Examples: | ||||
| Function views | ||||
|     1. Add an import:  from my_app import views | ||||
|     2. Add a URL to urlpatterns:  path('', views.home, name='home') | ||||
| Class-based views | ||||
|     1. Add an import:  from other_app.views import Home | ||||
|     2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home') | ||||
| Including another URLconf | ||||
|     1. Import the include() function: from django.urls import include, path | ||||
|     2. Add a URL to urlpatterns:  path('blog/', include('blog.urls')) | ||||
| """ | ||||
| from django.contrib import admin | ||||
| from django.urls import path, include | ||||
| from arr_api.views import ArrIndexView | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path('admin/', admin.site.urls), | ||||
|     path("", ArrIndexView.as_view(), name="home"), | ||||
|     path("settings/", include("settingspanel.urls")), | ||||
|     path("api/", include("arr_api.urls")), | ||||
| ] | ||||
							
								
								
									
										16
									
								
								subscribarr/wsgi.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								subscribarr/wsgi.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| """ | ||||
| WSGI config for subscribarr project. | ||||
|  | ||||
| It exposes the WSGI callable as a module-level variable named ``application``. | ||||
|  | ||||
| For more information on this file, see | ||||
| https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ | ||||
| """ | ||||
|  | ||||
| import os | ||||
|  | ||||
| from django.core.wsgi import get_wsgi_application | ||||
|  | ||||
| os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'subscribarr.settings') | ||||
|  | ||||
| application = get_wsgi_application() | ||||
		Reference in New Issue
	
	Block a user