Squash history before public relese

This commit is contained in:
vas3k 2020-04-23 23:13:56 +02:00
commit c3f7a94bc5
282 changed files with 24573 additions and 0 deletions

39
.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
*.py[co]
# Packages
*.egg
*.egg-info
dist
build
eggs
parts
sdist
develop-eggs
.installed.cfg
# Installer logs
pip-log.txt
# Unit test / coverage reports
.coverage
.tox
# Translations
*.mo
# IDEA
.idea
# Mac OS X
.DS_Store
.mypy_cache
private_settings.py
local_settings.py
media/images
media/i
club/.env
db.sqlite3
*.session
venv/

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM python:3.8-slim-buster
ENV DEBIAN_FRONTEND noninteractive
RUN apt-get update \
&& apt-get dist-upgrade -y \
&& apt-get install --no-install-recommends -yq \
gcc \
libc-dev \
libpq-dev \
make \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ADD . /app
RUN pip install --no-cache-dir pipenv \
&& pipenv install --dev

49
Makefile Normal file
View File

@ -0,0 +1,49 @@
# Set the default goal if no targets were specified on the command line
.DEFAULT_GOAL = run
# Makes shell non-interactive and exit on any error
.SHELLFLAGS = -ec
PROJECT_NAME=vas3k_club
run-dev: ## Runs dev server
@pipenv run python manage.py runserver 0.0.0.0:8000
run-queue: ## Runs task broker
@pipenv run python manage.py qcluster
run-uvicorn: ## Runs production server using uvicorn (ASGI)
@pipenv run uvicorn --fd 0 club.asgi:application
docker-run-dev: ## Run dev server in docker
@pipenv run python ./utils/wait_for_postgres.py
@pipenv run python manage.py migrate
@pipenv run python manage.py runserver 0.0.0.0:8000
help: ## Display this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| sort \
| awk 'BEGIN {FS = ":.*?## "}; {printf "\033[0;32m%-30s\033[0m %s\n", $$1, $$2}'
lint: ## Lint code with flake8
@pipenv run flake8 $(PROJECT_NAME)
migrate: ## Migrate database to the latest version
@pipenv run python3 manage.py migrate
mypy: ## Check types with mypy
@pipenv run mypy .
test-ci: lint mypy ## Run tests (intended for CI usage)
.PHONY: \
dev-requirements \
docker-run-dev \
run-dev \
run-queue \
run-uvicorn \
help \
lint \
migrate \
mypy \
run \
test-ci

36
Pipfile Normal file
View File

@ -0,0 +1,36 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
flake8 = "*"
pytest = "*"
django-debug-toolbar = "*"
[packages]
django = "==3.0.4"
requests = "==2.23.0"
pyjwt = "==1.7.1"
cryptography = "==2.8"
patreon = ">=0.5"
sentry-sdk = "==0.14.3"
psycopg2-binary = "==2.8.4"
awesome-slugify = ">=1.6.5"
mistune = "==2.0.0a2"
click = "*"
pillow = ">=7.1.1"
django-simple-history = ">=2.8.0"
python-telegram-bot = "==12.5.1"
django-q = {extras = ["sentry"],version = "*"}
redis = ">=3.4.1"
python-dotenv = ">=0.12"
newspaper3k = ">=0.2.8"
django-redis = "==4.11.0"
nltk = "==3.4.5"
gunicorn = "==20.0.4"
uvicorn = "*"
premailer = "==3.6.1"
[requires]
python_version = "3.8"

793
Pipfile.lock generated Normal file
View File

@ -0,0 +1,793 @@
{
"_meta": {
"hash": {
"sha256": "55ce8408dfb50c4a69d205126f5ea3670540f63bdd6a385a24f719de7c158ae6"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.8"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"arrow": {
"hashes": [
"sha256:5390e464e2c5f76971b60ffa7ee29c598c7501a294bc9f5e6dadcb251a5d027b",
"sha256:70729bcc831da496ca3cb4b7e89472c8e2d27d398908155e0796179f6d2d41ee"
],
"version": "==0.15.5"
},
"asgiref": {
"hashes": [
"sha256:8036f90603c54e93521e5777b2b9a39ba1bad05773fcf2d208f0299d1df58ce5",
"sha256:9ca8b952a0a9afa61d30aa6d3d9b570bb3fd6bafcf7ec9e6bed43b936133db1c"
],
"version": "==3.2.7"
},
"awesome-slugify": {
"hashes": [
"sha256:bbdec3fa2187917473a2efad092b57f7125a55f841a7cf6a1773178d32ccfd71"
],
"index": "pypi",
"version": "==1.6.5"
},
"beautifulsoup4": {
"hashes": [
"sha256:594ca51a10d2b3443cbac41214e12dbb2a1cd57e1a7344659849e2e20ba6a8d8",
"sha256:a4bbe77fd30670455c5296242967a123ec28c37e9702a8a81bd2f20a4baf0368",
"sha256:d4e96ac9b0c3a6d3f0caae2e4124e6055c5dcafde8e2f831ff194c104f0775a0"
],
"version": "==4.9.0"
},
"blessed": {
"hashes": [
"sha256:2e1f368ed67da152fcaea4ce8cd54655972fd4f6808a0e11a1242642196b5130",
"sha256:320a619c83298a9c9d632dbd8fafbb90ba9a38b83c7e64726c572fb186dd0781"
],
"version": "==1.17.4"
},
"cachetools": {
"hashes": [
"sha256:1d057645db16ca7fe1f3bd953558897603d6f0b9c51ed9d11eb4d071ec4e2aab",
"sha256:de5d88f87781602201cde465d3afe837546663b168e8b39df67411b0bf10cefc"
],
"version": "==4.1.0"
},
"certifi": {
"hashes": [
"sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304",
"sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"
],
"version": "==2020.4.5.1"
},
"cffi": {
"hashes": [
"sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff",
"sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b",
"sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac",
"sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0",
"sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384",
"sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26",
"sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6",
"sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b",
"sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e",
"sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd",
"sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2",
"sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66",
"sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc",
"sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8",
"sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55",
"sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4",
"sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5",
"sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d",
"sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78",
"sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa",
"sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793",
"sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f",
"sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a",
"sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f",
"sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30",
"sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f",
"sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3",
"sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c"
],
"version": "==1.14.0"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"click": {
"hashes": [
"sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc",
"sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a"
],
"index": "pypi",
"version": "==7.1.1"
},
"cryptography": {
"hashes": [
"sha256:02079a6addc7b5140ba0825f542c0869ff4df9a69c360e339ecead5baefa843c",
"sha256:1df22371fbf2004c6f64e927668734070a8953362cd8370ddd336774d6743595",
"sha256:369d2346db5934345787451504853ad9d342d7f721ae82d098083e1f49a582ad",
"sha256:3cda1f0ed8747339bbdf71b9f38ca74c7b592f24f65cdb3ab3765e4b02871651",
"sha256:44ff04138935882fef7c686878e1c8fd80a723161ad6a98da31e14b7553170c2",
"sha256:4b1030728872c59687badcca1e225a9103440e467c17d6d1730ab3d2d64bfeff",
"sha256:58363dbd966afb4f89b3b11dfb8ff200058fbc3b947507675c19ceb46104b48d",
"sha256:6ec280fb24d27e3d97aa731e16207d58bd8ae94ef6eab97249a2afe4ba643d42",
"sha256:7270a6c29199adc1297776937a05b59720e8a782531f1f122f2eb8467f9aab4d",
"sha256:73fd30c57fa2d0a1d7a49c561c40c2f79c7d6c374cc7750e9ac7c99176f6428e",
"sha256:7f09806ed4fbea8f51585231ba742b58cbcfbfe823ea197d8c89a5e433c7e912",
"sha256:90df0cc93e1f8d2fba8365fb59a858f51a11a394d64dbf3ef844f783844cc793",
"sha256:971221ed40f058f5662a604bd1ae6e4521d84e6cad0b7b170564cc34169c8f13",
"sha256:a518c153a2b5ed6b8cc03f7ae79d5ffad7315ad4569b2d5333a13c38d64bd8d7",
"sha256:b0de590a8b0979649ebeef8bb9f54394d3a41f66c5584fff4220901739b6b2f0",
"sha256:b43f53f29816ba1db8525f006fa6f49292e9b029554b3eb56a189a70f2a40879",
"sha256:d31402aad60ed889c7e57934a03477b572a03af7794fa8fb1780f21ea8f6551f",
"sha256:de96157ec73458a7f14e3d26f17f8128c959084931e8997b9e655a39c8fde9f9",
"sha256:df6b4dca2e11865e6cfbfb708e800efb18370f5a46fd601d3755bc7f85b3a8a2",
"sha256:ecadccc7ba52193963c0475ac9f6fa28ac01e01349a2ca48509667ef41ffd2cf",
"sha256:fb81c17e0ebe3358486cd8cc3ad78adbae58af12fc2bf2bc0bb84e8090fa5ce8"
],
"index": "pypi",
"version": "==2.8"
},
"cssselect": {
"hashes": [
"sha256:f612ee47b749c877ebae5bb77035d8f4202c6ad0f0fc1271b3c18ad6c4468ecf",
"sha256:f95f8dedd925fd8f54edb3d2dfb44c190d9d18512377d3c1e2388d16126879bc"
],
"version": "==1.1.0"
},
"cssutils": {
"hashes": [
"sha256:a2fcf06467553038e98fea9cfe36af2bf14063eb147a70958cfcaa8f5786acaf",
"sha256:c74dbe19c92f5052774eadb15136263548dd013250f1ed1027988e7fef125c8d"
],
"version": "==1.0.2"
},
"decorator": {
"hashes": [
"sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760",
"sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"
],
"version": "==4.4.2"
},
"django": {
"hashes": [
"sha256:50b781f6cbeb98f673aa76ed8e572a019a45e52bdd4ad09001072dfd91ab07c8",
"sha256:89e451bfbb815280b137e33e454ddd56481fdaa6334054e6e031041ee1eda360"
],
"index": "pypi",
"version": "==3.0.4"
},
"django-picklefield": {
"hashes": [
"sha256:67a5e156343e3b032cac2f65565f0faa81635a99c7da74b0f07a0f5db467b646",
"sha256:e03cb181b7161af38ad6b573af127e4fe9b7cc2c455b42c1ec43eaad525ade0a"
],
"version": "==2.1.1"
},
"django-q": {
"extras": [
"sentry"
],
"hashes": [
"sha256:2c96e950db3c1034f77a9c5904150407fbc4fbfb15ecc175619e2ee82e91a61e",
"sha256:60f251b1a0754988ed6594604114f26e4d5d480b955064dc505dcce4031ba8e8"
],
"index": "pypi",
"version": "==1.2.1"
},
"django-q-sentry": {
"hashes": [
"sha256:4864022c7bd8e456ea51401830fc1ab37977099ca29ab9b6c0d35c4988c4aad9",
"sha256:b864b26ccfc09aafa948d8c8752a005a0fc1008240748d7424d9e4d750dbbb0d"
],
"version": "==0.1.1"
},
"django-redis": {
"hashes": [
"sha256:a5b1e3ffd3198735e6c529d9bdf38ca3fcb3155515249b98dc4d966b8ddf9d2b",
"sha256:e1aad4cc5bd743d8d0b13d5cae0cef5410eaace33e83bff5fc3a139ad8db50b4"
],
"index": "pypi",
"version": "==4.11.0"
},
"django-simple-history": {
"hashes": [
"sha256:3d56ca81de5a960b293dd8be31af991b976f319940e01c68ed10652dbd86aa58",
"sha256:831cfc3f1164627428be3cd38fb7268eb7a979f97f11018e55813a3ce23b1173"
],
"index": "pypi",
"version": "==2.8.0"
},
"feedfinder2": {
"hashes": [
"sha256:3701ee01a6c85f8b865a049c30ba0b4608858c803fe8e30d1d289fdbe89d0efe"
],
"version": "==0.0.4"
},
"feedparser": {
"hashes": [
"sha256:bd030652c2d08532c034c27fcd7c85868e7fa3cb2b17f230a44a6bbc92519bf9",
"sha256:cd2485472e41471632ed3029d44033ee420ad0b57111db95c240c9160a85831c",
"sha256:ce875495c90ebd74b179855449040003a1beb40cd13d5f037a0654251e260b02"
],
"version": "==5.2.1"
},
"future": {
"hashes": [
"sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"
],
"version": "==0.18.2"
},
"gunicorn": {
"hashes": [
"sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626",
"sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"
],
"index": "pypi",
"version": "==20.0.4"
},
"h11": {
"hashes": [
"sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1",
"sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1"
],
"version": "==0.9.0"
},
"httptools": {
"hashes": [
"sha256:0a4b1b2012b28e68306575ad14ad5e9120b34fccd02a81eb08838d7e3bbb48be",
"sha256:3592e854424ec94bd17dc3e0c96a64e459ec4147e6d53c0a42d0ebcef9cb9c5d",
"sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce",
"sha256:56b6393c6ac7abe632f2294da53f30d279130a92e8ae39d8d14ee2e1b05ad1f2",
"sha256:86c6acd66765a934e8730bf0e9dfaac6fdcf2a4334212bd4a0a1c78f16475ca6",
"sha256:96da81e1992be8ac2fd5597bf0283d832287e20cb3cfde8996d2b00356d4e17f",
"sha256:96eb359252aeed57ea5c7b3d79839aaa0382c9d3149f7d24dd7172b1bcecb009",
"sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce",
"sha256:ac0aa11e99454b6a66989aa2d44bca41d4e0f968e395a0a8f164b401fefe359a",
"sha256:bc3114b9edbca5a1eb7ae7db698c669eb53eb8afbbebdde116c174925260849c",
"sha256:fa3cd71e31436911a44620473e873a256851e1f53dee56669dae403ba41756a4",
"sha256:fea04e126014169384dee76a153d4573d90d0cbd1d12185da089f73c78390437"
],
"markers": "sys_platform != 'win32' and sys_platform != 'cygwin' and platform_python_implementation != 'PyPy'",
"version": "==0.1.1"
},
"idna": {
"hashes": [
"sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",
"sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"
],
"version": "==2.9"
},
"jieba3k": {
"hashes": [
"sha256:980a4f2636b778d312518066be90c7697d410dd5a472385f5afced71a2db1c10"
],
"version": "==0.35.1"
},
"lxml": {
"hashes": [
"sha256:06d4e0bbb1d62e38ae6118406d7cdb4693a3fa34ee3762238bcb96c9e36a93cd",
"sha256:0701f7965903a1c3f6f09328c1278ac0eee8f56f244e66af79cb224b7ef3801c",
"sha256:1f2c4ec372bf1c4a2c7e4bb20845e8bcf8050365189d86806bad1e3ae473d081",
"sha256:4235bc124fdcf611d02047d7034164897ade13046bda967768836629bc62784f",
"sha256:5828c7f3e615f3975d48f40d4fe66e8a7b25f16b5e5705ffe1d22e43fb1f6261",
"sha256:585c0869f75577ac7a8ff38d08f7aac9033da2c41c11352ebf86a04652758b7a",
"sha256:5d467ce9c5d35b3bcc7172c06320dddb275fea6ac2037f72f0a4d7472035cea9",
"sha256:63dbc21efd7e822c11d5ddbedbbb08cd11a41e0032e382a0fd59b0b08e405a3a",
"sha256:7bc1b221e7867f2e7ff1933165c0cec7153dce93d0cdba6554b42a8beb687bdb",
"sha256:8620ce80f50d023d414183bf90cc2576c2837b88e00bea3f33ad2630133bbb60",
"sha256:8a0ebda56ebca1a83eb2d1ac266649b80af8dd4b4a3502b2c1e09ac2f88fe128",
"sha256:90ed0e36455a81b25b7034038e40880189169c308a3df360861ad74da7b68c1a",
"sha256:95e67224815ef86924fbc2b71a9dbd1f7262384bca4bc4793645794ac4200717",
"sha256:afdb34b715daf814d1abea0317b6d672476b498472f1e5aacbadc34ebbc26e89",
"sha256:b4b2c63cc7963aedd08a5f5a454c9f67251b1ac9e22fd9d72836206c42dc2a72",
"sha256:d068f55bda3c2c3fcaec24bd083d9e2eede32c583faf084d6e4b9daaea77dde8",
"sha256:d5b3c4b7edd2e770375a01139be11307f04341ec709cf724e0f26ebb1eef12c3",
"sha256:deadf4df349d1dcd7b2853a2c8796593cc346600726eff680ed8ed11812382a7",
"sha256:df533af6f88080419c5a604d0d63b2c33b1c0c4409aba7d0cb6de305147ea8c8",
"sha256:e4aa948eb15018a657702fee0b9db47e908491c64d36b4a90f59a64741516e77",
"sha256:e5d842c73e4ef6ed8c1bd77806bf84a7cb535f9c0cf9b2c74d02ebda310070e1",
"sha256:ebec08091a22c2be870890913bdadd86fcd8e9f0f22bcb398abd3af914690c15",
"sha256:edc15fcfd77395e24543be48871c251f38132bb834d9fdfdad756adb6ea37679",
"sha256:f2b74784ed7e0bc2d02bd53e48ad6ba523c9b36c194260b7a5045071abbb1012",
"sha256:fa071559f14bd1e92077b1b5f6c22cf09756c6de7139370249eb372854ce51e6",
"sha256:fd52e796fee7171c4361d441796b64df1acfceb51f29e545e812f16d023c4bbc",
"sha256:fe976a0f1ef09b3638778024ab9fb8cde3118f203364212c198f71341c0715ca"
],
"version": "==4.5.0"
},
"mistune": {
"hashes": [
"sha256:1ee1415cab4f8722c4d1e36c060c00b09e3e5c0abcd3bb4a4e424eb9950fa0ed",
"sha256:dffb6b7a92a341b95e5432fab8532f2731f903165c25fe2fbef8f4218374acaa"
],
"index": "pypi",
"version": "==2.0.0a2"
},
"newspaper3k": {
"hashes": [
"sha256:44a864222633d3081113d1030615991c3dbba87239f6bbf59d91240f71a22e3e",
"sha256:9f1bd3e1fb48f400c715abf875cc7b0a67b7ddcd87f50c9aeeb8fcbbbd9004fb"
],
"index": "pypi",
"version": "==0.2.8"
},
"nltk": {
"hashes": [
"sha256:bed45551259aa2101381bbdd5df37d44ca2669c5c3dad72439fa459b29137d94"
],
"index": "pypi",
"version": "==3.4.5"
},
"patreon": {
"hashes": [
"sha256:04ad0360e7acc38a85beafa8d44eeeafd3c31d136488aa4de707526163682ca4"
],
"index": "pypi",
"version": "==0.5.0"
},
"pillow": {
"hashes": [
"sha256:04a10558320eba9137d6a78ca6fc8f4a5801f1b971152938851dc4629d903579",
"sha256:0f89ddc77cf421b8cd34ae852309501458942bf370831b4a9b406156b599a14e",
"sha256:251e5618125ec12ac800265d7048f5857a8f8f1979db9ea3e11382e159d17f68",
"sha256:291bad7097b06d648222b769bbfcd61e40d0abdfe10df686d20ede36eb8162b6",
"sha256:2f0b52a08d175f10c8ea36685115681a484c55d24d0933f9fd911e4111c04144",
"sha256:3713386d1e9e79cea1c5e6aaac042841d7eef838cc577a3ca153c8bedf570287",
"sha256:433bbc2469a2351bea53666d97bb1eb30f0d56461735be02ea6b27654569f80f",
"sha256:4510c6b33277970b1af83c987277f9a08ec2b02cc20ac0f9234e4026136bb137",
"sha256:50a10b048f4dd81c092adad99fa5f7ba941edaf2f9590510109ac2a15e706695",
"sha256:670e58d3643971f4afd79191abd21623761c2ebe61db1c2cb4797d817c4ba1a7",
"sha256:6c1924ed7dbc6ad0636907693bbbdd3fdae1d73072963e71f5644b864bb10b4d",
"sha256:721c04d3c77c38086f1f95d1cd8df87f2f9a505a780acf8575912b3206479da1",
"sha256:8d5799243050c2833c2662b824dfb16aa98e408d2092805edea4300a408490e7",
"sha256:90cd441a1638ae176eab4d8b6b94ab4ec24b212ed4c3fbee2a6e74672481d4f8",
"sha256:a5dc9f28c0239ec2742d4273bd85b2aa84655be2564db7ad1eb8f64b1efcdc4c",
"sha256:b2f3e8cc52ecd259b94ca880fea0d15f4ebc6da2cd3db515389bb878d800270f",
"sha256:b7453750cf911785009423789d2e4e5393aae9cbb8b3f471dab854b85a26cb89",
"sha256:b99b2607b6cd58396f363b448cbe71d3c35e28f03e442ab00806463439629c2c",
"sha256:cd47793f7bc9285a88c2b5551d3f16a2ddd005789614a34c5f4a598c2a162383",
"sha256:d6bf085f6f9ec6a1724c187083b37b58a8048f86036d42d21802ed5d1fae4853",
"sha256:da737ab273f4d60ae552f82ad83f7cbd0e173ca30ca20b160f708c92742ee212",
"sha256:eb84e7e5b07ff3725ab05977ac56d5eeb0c510795aeb48e8b691491be3c5745b"
],
"index": "pypi",
"version": "==7.1.1"
},
"premailer": {
"hashes": [
"sha256:d5aa0cba8687a231a2a43d9021735ed02a166dbf9c2b1669df22bfc863e5d948",
"sha256:fcc1062329ba37668f95b2bf95e78d730eebf7851d742028251384a04e87fa22"
],
"index": "pypi",
"version": "==3.6.1"
},
"psycopg2-binary": {
"hashes": [
"sha256:040234f8a4a8dfd692662a8308d78f63f31a97e1c42d2480e5e6810c48966a29",
"sha256:086f7e89ec85a6704db51f68f0dcae432eff9300809723a6e8782c41c2f48e03",
"sha256:18ca813fdb17bc1db73fe61b196b05dd1ca2165b884dd5ec5568877cabf9b039",
"sha256:19dc39616850342a2a6db70559af55b22955f86667b5f652f40c0e99253d9881",
"sha256:2166e770cb98f02ed5ee2b0b569d40db26788e0bf2ec3ae1a0d864ea6f1d8309",
"sha256:3a2522b1d9178575acee4adf8fd9f979f9c0449b00b4164bb63c3475ea6528ed",
"sha256:3aa773580f85a28ffdf6f862e59cb5a3cc7ef6885121f2de3fca8d6ada4dbf3b",
"sha256:3b5deaa3ee7180585a296af33e14c9b18c218d148e735c7accf78130765a47e3",
"sha256:407af6d7e46593415f216c7f56ba087a9a42bd6dc2ecb86028760aa45b802bd7",
"sha256:4c3c09fb674401f630626310bcaf6cd6285daf0d5e4c26d6e55ca26a2734e39b",
"sha256:4c6717962247445b4f9e21c962ea61d2e884fc17df5ddf5e35863b016f8a1f03",
"sha256:50446fae5681fc99f87e505d4e77c9407e683ab60c555ec302f9ac9bffa61103",
"sha256:5057669b6a66aa9ca118a2a860159f0ee3acf837eda937bdd2a64f3431361a2d",
"sha256:5dd90c5438b4f935c9d01fcbad3620253da89d19c1f5fca9158646407ed7df35",
"sha256:659c815b5b8e2a55193ede2795c1e2349b8011497310bb936da7d4745652823b",
"sha256:69b13fdf12878b10dc6003acc8d0abf3ad93e79813fd5f3812497c1c9fb9be49",
"sha256:7a1cb80e35e1ccea3e11a48afe65d38744a0e0bde88795cc56a4d05b6e4f9d70",
"sha256:7e6e3c52e6732c219c07bd97fff6c088f8df4dae3b79752ee3a817e6f32e177e",
"sha256:7f42a8490c4fe854325504ce7a6e4796b207960dabb2cbafe3c3959cb00d1d7e",
"sha256:84156313f258eafff716b2961644a4483a9be44a5d43551d554844d15d4d224e",
"sha256:8578d6b8192e4c805e85f187bc530d0f52ba86c39172e61cd51f68fddd648103",
"sha256:890167d5091279a27e2505ff0e1fb273f8c48c41d35c5b92adbf4af80e6b2ed6",
"sha256:98e10634792ac0e9e7a92a76b4991b44c2325d3e7798270a808407355e7bb0a1",
"sha256:9aadff9032e967865f9778485571e93908d27dab21d0fdfdec0ca779bb6f8ad9",
"sha256:9f24f383a298a0c0f9b3113b982e21751a8ecde6615494a3f1470eb4a9d70e9e",
"sha256:a73021b44813b5c84eda4a3af5826dd72356a900bac9bd9dd1f0f81ee1c22c2f",
"sha256:afd96845e12638d2c44d213d4810a08f4dc4a563f9a98204b7428e567014b1cd",
"sha256:b73ddf033d8cd4cc9dfed6324b1ad2a89ba52c410ef6877998422fcb9c23e3a8",
"sha256:b8f490f5fad1767a1331df1259763b3bad7d7af12a75b950c2843ba319b2415f",
"sha256:dbc5cd56fff1a6152ca59445178652756f4e509f672e49ccdf3d79c1043113a4",
"sha256:eac8a3499754790187bb00574ab980df13e754777d346f85e0ff6df929bcd964",
"sha256:eaed1c65f461a959284649e37b5051224f4db6ebdc84e40b5e65f2986f101a08"
],
"index": "pypi",
"version": "==2.8.4"
},
"pycparser": {
"hashes": [
"sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0",
"sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"
],
"version": "==2.20"
},
"pyjwt": {
"hashes": [
"sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e",
"sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96"
],
"index": "pypi",
"version": "==1.7.1"
},
"python-dateutil": {
"hashes": [
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
],
"version": "==2.8.1"
},
"python-dotenv": {
"hashes": [
"sha256:25c0ff1a3e12f4bde8d592cc254ab075cfe734fc5dd989036716fd17ee7e5ec7",
"sha256:3b9909bc96b0edc6b01586e1eed05e71174ef4e04c71da5786370cebea53ad74"
],
"index": "pypi",
"version": "==0.13.0"
},
"python-telegram-bot": {
"hashes": [
"sha256:6d69fb04dc8d83e01cb25a0d83aaa2a1a4b7d5b14cd38b9f8922a567b4a4a510",
"sha256:af36fbf051415ded8b2633f27b71d292efcd85d58a647a1f138db191380144ff"
],
"index": "pypi",
"version": "==12.5.1"
},
"pytz": {
"hashes": [
"sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d",
"sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"
],
"version": "==2019.3"
},
"pyyaml": {
"hashes": [
"sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
"sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
"sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
"sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
"sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
"sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
"sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
"sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
"sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
"sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
"sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
],
"version": "==5.3.1"
},
"redis": {
"hashes": [
"sha256:0dcfb335921b88a850d461dc255ff4708294943322bd55de6cfd68972490ca1f",
"sha256:b205cffd05ebfd0a468db74f0eedbff8df1a7bfc47521516ade4692991bb0833"
],
"index": "pypi",
"version": "==3.4.1"
},
"regex": {
"hashes": [
"sha256:08119f707f0ebf2da60d2f24c2f39ca616277bb67ef6c92b72cbf90cbe3a556b",
"sha256:0ce9537396d8f556bcfc317c65b6a0705320701e5ce511f05fc04421ba05b8a8",
"sha256:1cbe0fa0b7f673400eb29e9ef41d4f53638f65f9a2143854de6b1ce2899185c3",
"sha256:2294f8b70e058a2553cd009df003a20802ef75b3c629506be20687df0908177e",
"sha256:23069d9c07e115537f37270d1d5faea3e0bdded8279081c4d4d607a2ad393683",
"sha256:24f4f4062eb16c5bbfff6a22312e8eab92c2c99c51a02e39b4eae54ce8255cd1",
"sha256:295badf61a51add2d428a46b8580309c520d8b26e769868b922750cf3ce67142",
"sha256:2a3bf8b48f8e37c3a40bb3f854bf0121c194e69a650b209628d951190b862de3",
"sha256:4385f12aa289d79419fede43f979e372f527892ac44a541b5446617e4406c468",
"sha256:5635cd1ed0a12b4c42cce18a8d2fb53ff13ff537f09de5fd791e97de27b6400e",
"sha256:5bfed051dbff32fd8945eccca70f5e22b55e4148d2a8a45141a3b053d6455ae3",
"sha256:7e1037073b1b7053ee74c3c6c0ada80f3501ec29d5f46e42669378eae6d4405a",
"sha256:90742c6ff121a9c5b261b9b215cb476eea97df98ea82037ec8ac95d1be7a034f",
"sha256:a58dd45cb865be0ce1d5ecc4cfc85cd8c6867bea66733623e54bd95131f473b6",
"sha256:c087bff162158536387c53647411db09b6ee3f9603c334c90943e97b1052a156",
"sha256:c162a21e0da33eb3d31a3ac17a51db5e634fc347f650d271f0305d96601dc15b",
"sha256:c9423a150d3a4fc0f3f2aae897a59919acd293f4cb397429b120a5fcd96ea3db",
"sha256:ccccdd84912875e34c5ad2d06e1989d890d43af6c2242c6fcfa51556997af6cd",
"sha256:e91ba11da11cf770f389e47c3f5c30473e6d85e06d7fd9dcba0017d2867aab4a",
"sha256:ea4adf02d23b437684cd388d557bf76e3afa72f7fed5bbc013482cc00c816948",
"sha256:fb95debbd1a824b2c4376932f2216cc186912e389bdb0e27147778cf6acb3f89"
],
"version": "==2020.4.4"
},
"requests": {
"hashes": [
"sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
"sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
],
"index": "pypi",
"version": "==2.23.0"
},
"requests-file": {
"hashes": [
"sha256:75c175eed739270aec3c5279ffd74e6527dada275c5c0d76b5817e9c86bb7dea",
"sha256:8f04aa6201bacda0567e7ac7f677f1499b0fc76b22140c54bc06edf1ba92e2fa"
],
"version": "==1.4.3"
},
"sentry-sdk": {
"hashes": [
"sha256:23808d571d2461a4ce3784ec12bbee5bdb8c026c143fe79d36cef8a6d653e71f",
"sha256:bb90a4e19c7233a580715fc986cc44be2c48fc10b31e71580a2037e1c94b6950"
],
"index": "pypi",
"version": "==0.14.3"
},
"six": {
"hashes": [
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
],
"version": "==1.14.0"
},
"soupsieve": {
"hashes": [
"sha256:e914534802d7ffd233242b785229d5ba0766a7f487385e3f714446a07bf540ae",
"sha256:fcd71e08c0aee99aca1b73f45478549ee7e7fc006d51b37bec9e9def7dc22b69"
],
"version": "==2.0"
},
"sqlparse": {
"hashes": [
"sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e",
"sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548"
],
"version": "==0.3.1"
},
"tinysegmenter": {
"hashes": [
"sha256:ed1f6d2e806a4758a73be589754384cbadadc7e1a414c81a166fc9adf2d40c6d"
],
"version": "==0.3"
},
"tldextract": {
"hashes": [
"sha256:16b2f7e81d89c2a5a914d25bdbddd3932c31a6b510db886c3ce0764a195c0ee7",
"sha256:9aa21a1f7827df4209e242ec4fc2293af5940ec730cde46ea80f66ed97bfc808"
],
"version": "==2.2.2"
},
"tornado": {
"hashes": [
"sha256:0fe2d45ba43b00a41cd73f8be321a44936dc1aba233dee979f17a042b83eb6dc",
"sha256:22aed82c2ea340c3771e3babc5ef220272f6fd06b5108a53b4976d0d722bcd52",
"sha256:2c027eb2a393d964b22b5c154d1a23a5f8727db6fda837118a776b29e2b8ebc6",
"sha256:5217e601700f24e966ddab689f90b7ea4bd91ff3357c3600fa1045e26d68e55d",
"sha256:5618f72e947533832cbc3dec54e1dffc1747a5cb17d1fd91577ed14fa0dc081b",
"sha256:5f6a07e62e799be5d2330e68d808c8ac41d4a259b9cea61da4101b83cb5dc673",
"sha256:c58d56003daf1b616336781b26d184023ea4af13ae143d9dda65e31e534940b9",
"sha256:c952975c8ba74f546ae6de2e226ab3cc3cc11ae47baf607459a6728585bb542a",
"sha256:c98232a3ac391f5faea6821b53db8db461157baa788f5d6222a193e9456e1740"
],
"version": "==6.0.4"
},
"unidecode": {
"hashes": [
"sha256:280a6ab88e1f2eb5af79edff450021a0d3f0448952847cd79677e55e58bad051",
"sha256:61f807220eda0203a774a09f84b4304a3f93b5944110cc132af29ddb81366883"
],
"version": "==0.4.21"
},
"urllib3": {
"hashes": [
"sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527",
"sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"
],
"version": "==1.25.9"
},
"uvicorn": {
"hashes": [
"sha256:0f58170165c4495f563d8224b2f415a0829af0412baa034d6f777904613087fd",
"sha256:6fdaf8e53bf1b2ddf0fe9ed06079b5348d7d1d87b3365fe2549e6de0d49e631c"
],
"index": "pypi",
"version": "==0.11.3"
},
"uvloop": {
"hashes": [
"sha256:08b109f0213af392150e2fe6f81d33261bb5ce968a288eb698aad4f46eb711bd",
"sha256:123ac9c0c7dd71464f58f1b4ee0bbd81285d96cdda8bc3519281b8973e3a461e",
"sha256:4315d2ec3ca393dd5bc0b0089d23101276778c304d42faff5dc4579cb6caef09",
"sha256:4544dcf77d74f3a84f03dd6278174575c44c67d7165d4c42c71db3fdc3860726",
"sha256:afd5513c0ae414ec71d24f6f123614a80f3d27ca655a4fcf6cabe50994cc1891",
"sha256:b4f591aa4b3fa7f32fb51e2ee9fea1b495eb75b0b3c8d0ca52514ad675ae63f7",
"sha256:bcac356d62edd330080aed082e78d4b580ff260a677508718f88016333e2c9c5",
"sha256:e7514d7a48c063226b7d06617cbb12a14278d4323a065a8d46a7962686ce2e95",
"sha256:f07909cd9fc08c52d294b1570bba92186181ca01fe3dc9ffba68955273dd7362"
],
"markers": "sys_platform != 'win32' and sys_platform != 'cygwin' and platform_python_implementation != 'PyPy'",
"version": "==0.14.0"
},
"wcwidth": {
"hashes": [
"sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1",
"sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"
],
"version": "==0.1.9"
},
"websockets": {
"hashes": [
"sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5",
"sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5",
"sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308",
"sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb",
"sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a",
"sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c",
"sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170",
"sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422",
"sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8",
"sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485",
"sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f",
"sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8",
"sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc",
"sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779",
"sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989",
"sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1",
"sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092",
"sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824",
"sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d",
"sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55",
"sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36",
"sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"
],
"version": "==8.1"
}
},
"develop": {
"asgiref": {
"hashes": [
"sha256:8036f90603c54e93521e5777b2b9a39ba1bad05773fcf2d208f0299d1df58ce5",
"sha256:9ca8b952a0a9afa61d30aa6d3d9b570bb3fd6bafcf7ec9e6bed43b936133db1c"
],
"version": "==3.2.7"
},
"attrs": {
"hashes": [
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
],
"version": "==19.3.0"
},
"django": {
"hashes": [
"sha256:50b781f6cbeb98f673aa76ed8e572a019a45e52bdd4ad09001072dfd91ab07c8",
"sha256:89e451bfbb815280b137e33e454ddd56481fdaa6334054e6e031041ee1eda360"
],
"index": "pypi",
"version": "==3.0.4"
},
"django-debug-toolbar": {
"hashes": [
"sha256:eabbefe89881bbe4ca7c980ff102e3c35c8e8ad6eb725041f538988f2f39a943",
"sha256:ff94725e7aae74b133d0599b9bf89bd4eb8f5d2c964106e61d11750228c8774c"
],
"index": "pypi",
"version": "==2.2"
},
"entrypoints": {
"hashes": [
"sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19",
"sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"
],
"version": "==0.3"
},
"flake8": {
"hashes": [
"sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb",
"sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"
],
"index": "pypi",
"version": "==3.7.9"
},
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
],
"version": "==0.6.1"
},
"more-itertools": {
"hashes": [
"sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c",
"sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"
],
"version": "==8.2.0"
},
"packaging": {
"hashes": [
"sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3",
"sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"
],
"version": "==20.3"
},
"pluggy": {
"hashes": [
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
],
"version": "==0.13.1"
},
"py": {
"hashes": [
"sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa",
"sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"
],
"version": "==1.8.1"
},
"pycodestyle": {
"hashes": [
"sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
"sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
],
"version": "==2.5.0"
},
"pyflakes": {
"hashes": [
"sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
"sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"
],
"version": "==2.1.1"
},
"pyparsing": {
"hashes": [
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
],
"version": "==2.4.7"
},
"pytest": {
"hashes": [
"sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172",
"sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970"
],
"index": "pypi",
"version": "==5.4.1"
},
"pytz": {
"hashes": [
"sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d",
"sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"
],
"version": "==2019.3"
},
"six": {
"hashes": [
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
],
"version": "==1.14.0"
},
"sqlparse": {
"hashes": [
"sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e",
"sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548"
],
"version": "==0.3.1"
},
"wcwidth": {
"hashes": [
"sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1",
"sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"
],
"version": "==0.1.9"
}
}
}

34
README.md Normal file
View File

@ -0,0 +1,34 @@
<div align="center">
<br>
<img src="frontend/static/images/logo/logo-256.png" alt="">
<h1>vas3k.club</h1>
</div>
Readme in progress...
## Installing and running
All you need is [Docker](https://www.docker.com/get-started).
Clone the repo
```
git clone git@github.com:vas3k/vas3k.club.git
cd vas3k.club
```
And then
```
docker-compose up
```
It will run the development server with all the necessary services. Auto-reload on any code change is active.
## Contributing
Contributions are welcome. Check out our [issues](https://github.com/vas3k/vas3k.club/issues) and [kanban-board](https://github.com/vas3k/vas3k.club/projects/1).
## License
MIT

0
auth/__init__.py Normal file
View File

5
auth/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class AuthConfig(AppConfig):
name = "auth"

View File

View File

@ -0,0 +1,2 @@
def me(request):
return {"me": request.me, "my_session": request.my_session}

9
auth/exceptions.py Normal file
View File

@ -0,0 +1,9 @@
from club.exceptions import ClubException
class AuthException(ClubException):
pass
class PatreonException(AuthException):
pass

95
auth/helpers.py Normal file
View File

@ -0,0 +1,95 @@
import logging
from datetime import datetime
from django.shortcuts import redirect, render
from auth.models import Session
from club.exceptions import AccessDenied
log = logging.getLogger(__name__)
def authorized_user_with_session(request):
token = request.COOKIES.get("token") or request.GET.get("token")
if not token:
return None, None
# TODO: don't cache it with user profile
# session = cache.get(f"token:{token}:session")
# if not session:
session = Session.objects\
.filter(token=token)\
.order_by()\
.select_related("user")\
.first()
# cache.set(f"token:{token}:session", session, timeout=60 * 60)
if not session or session.expires_at <= datetime.utcnow():
log.info("User session has expired")
return None, None # session is expired
return session.user, session
def authorized_user(request):
user, _ = authorized_user_with_session(request)
return user
def auth_required(view):
def wrapper(request, *args, **kwargs):
if not request.me:
return render(request, "auth/access_denied.html")
if request.me.membership_expires_at < datetime.utcnow():
log.info("User membership expired. Redirecting")
return redirect("membership_expired")
if not request.path.startswith("/profile/") \
and not request.path.startswith("/auth/") \
and not request.path.startswith("/intro/") \
and not request.path.startswith("/telegram/"):
if request.me.is_banned:
log.info("User banned. Redirecting")
return redirect("banned")
if not request.me.is_profile_complete:
log.info("User profile is not completed. Redirecting")
return redirect("intro")
if not request.me.is_profile_reviewed:
if request.me.is_profile_rejected:
log.info("User rejected. Redirecting")
return redirect("rejected")
log.info("User on review. Redirecting")
return redirect("on_review")
return view(request, *args, **kwargs)
return wrapper
def moderator_role_required(view):
def wrapper(request, *args, **kwargs):
if not request.me:
return redirect("login")
if not request.me.is_moderator:
raise AccessDenied()
return view(request, *args, **kwargs)
return wrapper
def auth_switch(no, yes):
def result(request, *args, **kwargs):
is_authorized = request.me is not None
if is_authorized:
return yes(request, *args, **kwargs)
else:
return no(request, *args, **kwargs)
return result

View File

View File

View File

@ -0,0 +1,76 @@
import logging
from datetime import datetime, timedelta
from django.core.management import BaseCommand
from auth.exceptions import PatreonException
from auth.models import Session
from auth.providers import patreon
from auth.providers.patreon import fetch_user_data
from users.models import User
log = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Fetches expiring Patreon accounts and tries to renew the subscription"
def add_arguments(self, parser):
parser.add_argument("--days-before", nargs=1, type=int, required=False, default=2)
parser.add_argument("--days-after", nargs=1, type=int, required=False, default=5)
def handle(self, *args, **options):
days_before = options["days_before"]
days_after = options["days_after"]
expiring_users = User.objects\
.filter(
membership_expires_at__gte=datetime.utcnow() - timedelta(days=days_before),
membership_expires_at__lte=datetime.utcnow() + timedelta(days=days_after),
)\
.all()
for user in expiring_users:
if user.membership_platform_type == User.MEMBERSHIP_PLATFORM_PATREON:
if not user.membership_platform_data or "refresh_token" not in user.membership_platform_data:
log.warning(f"No auth data for user: {user.slug}")
continue
self.stdout.write(f"Renewing for user {user.slug}")
# refresh user data id needed
try:
auth_data = patreon.refresh_auth_data(user.membership_platform_data["refresh_token"])
user.membership_platform_data = {
"access_token": auth_data["access_token"],
"refresh_token": auth_data["refresh_token"],
}
except PatreonException as ex:
log.warning(f"Can't refresh user data: {user.slug}: {ex}")
pass
# fetch user pledge status
try:
user_data = fetch_user_data(user.membership_platform_data["access_token"])
except PatreonException as ex:
log.exception(f"Something went wrong for user {user.slug}")
continue
# check the new expiration date
membership = patreon.parse_active_membership(user_data)
if membership:
if membership.expires_at >= user.membership_expires_at:
user.membership_expires_at = membership.expires_at
user.balance = membership.lifetime_support_cents / 100
# TODO: ^^^ remove when the real money comes in
self.stdout.write(f"New expiration date for user {user.slug}{membership.expires_at}")
else:
Session.objects.filter(user=user).delete()
user.save()
self.stdout.write(f"User processed: {user.slug}")
else:
self.stderr.write(f"No renewing scenario for the platform: {user.membership_platform_type}")
self.stdout.write("Done 🥙")

View File

@ -0,0 +1,45 @@
# Generated by Django 3.0.4 on 2020-04-08 10:09
import uuid
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Apps',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=64, unique=True)),
('secret_key', models.CharField(max_length=128, unique=True)),
('app_key', models.CharField(max_length=256, unique=True)),
('redirect_urls', models.TextField()),
],
options={
'db_table': 'apps',
},
),
migrations.CreateModel(
name='Session',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('token', models.CharField(db_index=True, max_length=128, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField(null=True)),
('app', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='auth.Apps')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='users.User')),
],
options={
'db_table': 'sessions',
},
),
]

View File

33
auth/models.py Normal file
View File

@ -0,0 +1,33 @@
from uuid import uuid4
from django.db import models
from users.models import User
class Apps(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField(max_length=64, unique=True)
secret_key = models.CharField(max_length=128, unique=True)
app_key = models.CharField(max_length=256, unique=True)
redirect_urls = models.TextField()
class Meta:
db_table = "apps"
class Session(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
user = models.ForeignKey(User, related_name="sessions", db_index=True, on_delete=models.CASCADE)
app = models.ForeignKey(Apps, related_name="sessions", null=True, on_delete=models.CASCADE)
token = models.CharField(max_length=128, unique=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True)
class Meta:
db_table = "sessions"

View File

22
auth/providers/common.py Normal file
View File

@ -0,0 +1,22 @@
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Optional
class Platform(str, Enum):
patreon = "patreon"
@dataclass
class Membership:
platform: Platform
user_id: str
full_name: str
email: str
image: Optional[str]
started_at: Optional[datetime]
charged_at: Optional[datetime]
expires_at: Optional[datetime]
lifetime_support_cents: Optional[int]
currently_entitled_amount_cents: int

150
auth/providers/patreon.py Normal file
View File

@ -0,0 +1,150 @@
import logging
from datetime import datetime, timedelta
from json import JSONDecodeError
from typing import Optional
import requests
from django.conf import settings
from auth.exceptions import PatreonException
from auth.providers.common import Membership, Platform
from utils.date import first_day_of_next_month
log = logging.getLogger(__name__)
def fetch_auth_data(code: str) -> dict:
try:
response = requests.post(
url=settings.PATREON_TOKEN_URL,
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"code": code,
"grant_type": "authorization_code",
"client_id": settings.PATREON_CLIENT_ID,
"client_secret": settings.PATREON_CLIENT_SECRET,
"redirect_uri": settings.PATREON_REDIRECT_URL,
},
)
except requests.exceptions.RequestException as ex:
if "invalid_grant" not in str(ex):
log.exception(f"Patreon error on login: {ex}")
raise PatreonException(ex)
if response.status_code >= 400:
log.error(f"Patreon error on login {response.status_code}: {response.text}")
raise PatreonException(response.text)
try:
return response.json()
except JSONDecodeError:
raise PatreonException("Patreon is down. Please try again")
def refresh_auth_data(refresh_token: str) -> dict:
try:
response = requests.post(
url=settings.PATREON_TOKEN_URL,
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"refresh_token": refresh_token,
"grant_type": "refresh_token",
"client_id": settings.PATREON_CLIENT_ID,
"client_secret": settings.PATREON_CLIENT_SECRET,
},
)
except requests.exceptions.RequestException as ex:
log.exception(f"Patreon error on refreshing token: {ex}")
raise PatreonException(ex)
if response.status_code >= 400:
log.error(
f"Patreon error on refreshing token {response.status_code}: {response.text}"
)
raise PatreonException(response.text)
try:
return response.json()
except JSONDecodeError:
raise PatreonException("Patreon is down. Please try again")
def fetch_user_data(access_token: str) -> dict:
try:
response = requests.get(
url=settings.PATREON_USER_URL,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Bearer {access_token}",
},
params={
"include": "memberships",
"fields[user]": "full_name,email,image_url,about",
"fields[member]": "patron_status,last_charge_status,last_charge_date,pledge_relationship_start,"
"lifetime_support_cents,currently_entitled_amount_cents",
},
)
except requests.exceptions.RequestException as ex:
log.exception(f"Patreon error on fetching user data: {ex}")
raise PatreonException(ex)
if response.status_code >= 400: # unauthorized etc
log.warning(
f"Patreon error on fetching user data {response.status_code}: {response.text}"
)
raise PatreonException(response.text)
try:
return response.json()
except JSONDecodeError:
raise PatreonException("Patreon is down. Please try again")
def parse_active_membership(user_data: dict) -> Optional[Membership]:
if not user_data or not user_data.get("data") or not user_data.get("included"):
return None
if user_data["data"]["id"] in settings.PATREON_GOD_IDS:
return Membership(
platform=Platform.patreon,
user_id=user_data["data"]["id"],
full_name=user_data["data"]["attributes"]["full_name"],
email=user_data["data"]["attributes"]["email"],
image=user_data["data"]["attributes"]["image_url"],
started_at=datetime.utcnow(),
charged_at=None,
expires_at=datetime.utcnow() + timedelta(days=100 * 365),
lifetime_support_cents=-1,
currently_entitled_amount_cents=0
)
for membership in user_data["included"]:
if membership["attributes"]["patron_status"] == "active_patron" \
and membership["attributes"]["last_charge_status"] == "Paid":
now = datetime.utcnow()
membership_started_at = datetime.strptime(
str(membership["attributes"]["pledge_relationship_start"])[:10], "%Y-%m-%d"
) if membership["attributes"]["pledge_relationship_start"] else now
last_charged_at = None
if membership["attributes"]["last_charge_date"]:
last_charged_at = datetime.strptime(
str(membership["attributes"]["last_charge_date"])[:10], "%Y-%m-%d"
)
return Membership(
platform=Platform.patreon,
user_id=user_data["data"]["id"],
full_name=user_data["data"]["attributes"]["full_name"],
email=user_data["data"]["attributes"]["email"],
image=None, # user_data["data"]["attributes"]["image_url"],
started_at=membership_started_at,
charged_at=last_charged_at,
expires_at=last_charged_at + timedelta(days=30) if last_charged_at else first_day_of_next_month(now),
lifetime_support_cents=int(membership["attributes"]["lifetime_support_cents"] or 0),
currently_entitled_amount_cents=int(membership["attributes"]["currently_entitled_amount_cents"] or 0),
)
return None

0
auth/views/__init__.py Normal file
View File

19
auth/views/auth.py Normal file
View File

@ -0,0 +1,19 @@
from django.core.cache import cache
from django.shortcuts import redirect, render
from auth.helpers import auth_required
from auth.models import Session
def login(request):
if request.me:
return redirect("profile", request.me.slug)
return render(request, "auth/login.html")
@auth_required
def logout(request):
token = request.COOKIES.get("token")
Session.objects.filter(token=token).delete()
cache.delete(f"token:{token}:session")
return redirect("index")

28
auth/views/external.py Normal file
View File

@ -0,0 +1,28 @@
from datetime import datetime
from urllib.parse import quote
import jwt
from django.conf import settings
from django.shortcuts import render, redirect
from django.urls import reverse
from auth.helpers import authorized_user
def external(request):
goto = request.GET.get("redirect")
if not goto:
return render(request, "error.html", {"message": "Нужен параметр ?redirect"})
me = authorized_user(request)
if not me:
redirect_here_again = quote(reverse("external") + f"?redirect={goto}", safe="")
return redirect(reverse("login") + f"?goto={redirect_here_again}")
payload = {
"user_id": me.id,
"user_name": me.name,
"exp": datetime.utcnow() + settings.JWT_EXP_TIMEDELTA,
}
jwt_token = jwt.encode(payload, settings.JWT_SECRET, settings.JWT_ALGORITHM).decode("utf-8")
return redirect(f"{goto}?jwt={jwt_token}")

123
auth/views/patreon.py Normal file
View File

@ -0,0 +1,123 @@
from datetime import datetime
from urllib.parse import urlencode
from django.conf import settings
from django.shortcuts import render, redirect
from django.urls import reverse
from auth.exceptions import PatreonException
from auth.helpers import authorized_user
from auth.models import Session
from auth.providers import patreon
from users.models import User
from utils.date import first_day_of_next_month
from utils.images import upload_image_from_url
from utils.strings import random_string
def patreon_login(request):
user = authorized_user(request)
if user:
return redirect("profile", user.slug)
state = {}
goto = request.GET.get("goto")
if goto:
state["goto"] = goto
query_string = urlencode(
{
"response_type": "code",
"client_id": settings.PATREON_CLIENT_ID,
"redirect_uri": settings.PATREON_REDIRECT_URL,
"scope": settings.PATREON_SCOPE,
"state": urlencode(state) if state else "",
}
)
return redirect(f"{settings.PATREON_AUTH_URL}?{query_string}")
def patreon_oauth_callback(request):
code = request.GET.get("code")
if not code:
return render(request, "error.html", {
"message": "Что-то сломалось между нами и патреоном. Так бывает. Попробуйте залогиниться еще раз."
})
try:
auth_data = patreon.fetch_auth_data(code)
user_data = patreon.fetch_user_data(auth_data["access_token"])
except PatreonException as ex:
if "invalid_grant" in str(ex):
return render(request, "error.html", {
"message": "Тут такое дело. Авторизация патреона — говно. "
"Она не сразу понимает, что вы стали моим патроном и отдаёт мне ошибку. "
"Я уже написал им в саппорт, но пока вам надо немного подождать и авторизоваться снова. "
"Обычно тогда срабатывает. Если нет — напишите мне в личные сообщения на патреоне."
})
return render(request, "error.html", {
"message": "Не получилось загрузить ваш профиль с серверов патреона. "
"Попробуйте еще раз, наверняка оно починится. "
f"Но если нет, то вот текст ошибки, с которым можно пожаловаться мне в личку:",
"data": str(ex)
})
membership = patreon.parse_active_membership(user_data)
if not membership:
return render(request, "error.html", {
"message": "Надо быть патроном чтобы состоять в клубе.<br>"
'<a href="https://www.patreon.com/join/vas3k">Станьте им здесь!</a>'
})
now = datetime.utcnow()
user, is_created = User.objects.get_or_create(
membership_platform_type=User.MEMBERSHIP_PLATFORM_PATREON,
membership_platform_id=membership.user_id,
defaults=dict(
email=membership.email,
full_name=membership.full_name[:120],
avatar=upload_image_from_url(membership.image) if membership.image else None,
membership_started_at=membership.started_at,
membership_expires_at=membership.expires_at,
created_at=now,
updated_at=now,
is_email_verified=False,
is_profile_complete=False, # redirect new users to an intro page
),
)
if is_created:
user.balance = membership.lifetime_support_cents / 100
else:
user.membership_expires_at = membership.expires_at
user.balance = membership.lifetime_support_cents / 100 # TODO: remove when the real money comes in
user.membership_platform_data = {
"access_token": auth_data["access_token"],
"refresh_token": auth_data["refresh_token"],
}
user.save()
session = Session.objects.create(
user=user,
token=random_string(length=32),
created_at=now,
expires_at=first_day_of_next_month(now),
)
redirect_to = reverse("profile", args=[user.slug])
state = request.GET.get("state")
if state:
redirect_to += f"?{state}"
response = redirect(redirect_to)
response.set_cookie(
key="token",
value=session.token,
max_age=settings.SESSION_COOKIE_AGE,
httponly=True,
secure=not settings.DEBUG,
)
return response

0
bot/__init__.py Normal file
View File

5
bot/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class BotConfig(AppConfig):
name = 'bot'

9
bot/bot.py Normal file
View File

@ -0,0 +1,9 @@
import logging
import telegram
from django.conf import settings
log = logging.getLogger()
bot = telegram.Bot(token=settings.TELEGRAM_TOKEN)
# dispatcher = Dispatcher(bot, None, workers=0, use_context=True)

79
bot/common.py Normal file
View File

@ -0,0 +1,79 @@
from collections import namedtuple
import telegram
from django.conf import settings
from django.template import loader
from telegram import ParseMode
from bot.bot import bot, log
Chat = namedtuple("Chat", ["id"])
ADMIN_CHAT = Chat(id=settings.TELEGRAM_ADMIN_CHAT_ID)
CLUB_CHAT = Chat(id=settings.TELEGRAM_CLUB_CHAT_ID)
CLUB_CHANNEL = Chat(id=settings.TELEGRAM_ONLINE_CHANNEL_ID)
def send_telegram_message(
chat: Chat,
text: str,
parse_mode: ParseMode = telegram.ParseMode.MARKDOWN,
disable_preview: bool = True,
**kwargs
):
if not bot:
log.warning("No telegram token. Skipping")
return
log.info(f"Telegram: sending the message: {text}")
return bot.send_message(
chat_id=chat.id,
text=text,
parse_mode=parse_mode,
disable_web_page_preview=disable_preview,
**kwargs
)
def send_telegram_image(
chat: Chat,
image_url: str,
text: str,
parse_mode: ParseMode = telegram.ParseMode.MARKDOWN,
**kwargs
):
if not bot:
log.warning("No telegram token. Skipping")
return
log.info(f"Telegram: sending the image: {image_url} {text}")
return bot.send_photo(
chat_id=chat.id,
photo=image_url,
caption=text[:1024],
parse_mode=parse_mode,
**kwargs
)
def remove_action_buttons(chat: Chat, message_id: str, **kwargs):
try:
return bot.edit_message_reply_markup(
chat_id=chat.id,
message_id=message_id,
reply_markup=None,
**kwargs
)
except telegram.error.BadRequest:
log.info("Buttons are already removed. Skipping")
return None
def render_html_message(template, **data):
template = loader.get_template(f"telegram/{template}")
return template.render({
**data,
"settings": settings
})

0
bot/handlers/__init__.py Normal file
View File

125
bot/handlers/moderator.py Normal file
View File

@ -0,0 +1,125 @@
from datetime import datetime
from django.conf import settings
from django.urls import reverse
from telegram import Update
from bot.common import send_telegram_message, ADMIN_CHAT, remove_action_buttons
from notifications.email.users import send_welcome_drink
from notifications.telegram.posts import notify_post_author_approved, notify_post_author_rejected
from notifications.telegram.users import notify_user_profile_approved, notify_user_profile_rejected
from posts.models import Post
from users.models import User
def process_moderator_actions(update):
# find an action processor
action_name, entity_id = update.callback_query.data.split(":", 1)
action = ACTIONS.get(action_name)
if not action:
send_telegram_message(
chat=ADMIN_CHAT,
text=f"😱 Неизвестная команда '{update.callback_query.data}'"
)
# run run run
try:
result, is_final = action(entity_id, update)
except Exception as ex:
send_telegram_message(
chat=ADMIN_CHAT,
text=f"❌ Экшен наебнулся '{update.callback_query.data}': {ex}"
)
return
# send results back to the chat
send_telegram_message(
chat=ADMIN_CHAT,
text=result
)
# hide admin buttons (to not allow people do the same thing twice)
if is_final:
remove_action_buttons(
chat=ADMIN_CHAT,
message_id=update.effective_message.message_id,
)
return result
def approve_post(post_id: str, update: Update) -> (str, bool):
post = Post.objects.get(id=post_id)
post.is_approved_by_moderator = True
post.last_activity_at = datetime.utcnow()
post.save()
notify_post_author_approved(post)
announce_post_url = settings.APP_HOST + reverse("announce_post", kwargs={
"post_slug": post.slug,
})
return f"👍 Пост «{post.title}» одобрен ({update.effective_user.full_name}). " \
f"Можно запостить его на канал вот здесь: {announce_post_url}", True
def forgive_post(post_id: str, update: Update) -> (str, bool):
post = Post.objects.get(id=post_id)
post.is_approved_by_moderator = False
post.save()
return f"😕 Пост «{post.title}» не одобрен, но оставлен на сайте ({update.effective_user.full_name})", True
def unpublish_post(post_id: str, update: Update) -> (str, bool):
post = Post.objects.get(id=post_id)
post.is_visible = False
post.save()
notify_post_author_rejected(post)
return f"👎 Пост «{post.title}» перенесен в черновики ({update.effective_user.full_name})", True
def approve_user_profile(user_id: str, update: Update) -> (str, bool):
user = User.objects.get(id=user_id)
if user.is_profile_reviewed and user.is_profile_complete:
return f"Пользователь «{user.full_name}» уже одобрен", True
user.is_profile_complete = True
user.is_profile_reviewed = True
user.is_profile_rejected = False
user.save()
# make intro visible
Post.objects\
.filter(author=user, type=Post.TYPE_INTRO)\
.update(is_visible=True, is_approved_by_moderator=True)
notify_user_profile_approved(user)
send_welcome_drink(user)
return f"✅ Пользователь «{user.full_name}» одобрен ({update.effective_user.full_name})", True
def reject_user_profile(user_id: str, update: Update) -> (str, bool):
user = User.objects.get(id=user_id)
user.is_profile_complete = True
user.is_profile_reviewed = True
user.is_profile_rejected = True
user.save()
notify_user_profile_rejected(user)
return f"❌ Пользователь «{user.full_name}» отклонен ({update.effective_user.full_name})", True
ACTIONS = {
"approve_post": approve_post,
"forgive_post": forgive_post,
"delete_post": unpublish_post,
"approve_user": approve_user_profile,
"reject_user": reject_user_profile,
}

200
bot/handlers/personal.py Normal file
View File

@ -0,0 +1,200 @@
import logging
import telegram
from django.conf import settings
from django.core.cache import cache
from django.urls import reverse
from telegram import Update
from bot.common import send_telegram_message, Chat
from posts.forms import POST_TYPE_MAP, PostTextForm
from posts.models import Post
from users.models import User
BOT_USER_POST_KEY = "bot:user:{}:post"
BOT_USER_POST_TTL = 60 * 60 * 48 # 48 hour
log = logging.getLogger(__name__)
def process_personal_chat_updates(update: Update):
user = User.objects.filter(telegram_id=update.effective_user.id).first()
if not user:
send_telegram_message(
chat=Chat(id=update.effective_chat.id),
text=f"😐 Извините, мы не знакомы. Привяжите свой аккаунт в профиле на https://vas3k.club"
)
return
# check for unfinished posts
unfinished_post = cached_post_get(update.effective_user.id)
# found an unfinished post
if unfinished_post:
reply = continue_posting(update, unfinished_post, user)
if reply:
send_telegram_message(
chat=Chat(id=update.effective_chat.id),
text=reply
)
return
# parse forwarded posts and links
if update.message:
reply = parse_forwarded_messages(update)
if reply:
send_telegram_message(
chat=Chat(id=update.effective_chat.id),
text=reply
)
return
send_telegram_message(
chat=Chat(id=update.effective_chat.id),
text="Чот непонятна 🤔"
)
def parse_forwarded_messages(update: Update):
started_post = {
"title": None,
"type": Post.TYPE_POST,
"text": update.message.text or update.message.caption,
"url": None,
"is_visible": True,
"is_public": True,
}
for entity, text in update.message.parse_entities().items():
if entity.type == "url":
started_post["url"] = text
elif entity.type == "bold":
started_post["title"] = text
# save it to user cache
cached_post_set(update.effective_user.id, started_post)
if started_post["url"]:
# looks like a link
send_telegram_message(
chat=Chat(id=update.effective_chat.id),
text=f"Выглядит как ссылка. Хотите поделиться ей в Клубе? Как будем постить?",
reply_markup=telegram.InlineKeyboardMarkup([
[
telegram.InlineKeyboardButton("🔗 Ссылкой", callback_data=f"link"),
telegram.InlineKeyboardButton("📝 Как пост", callback_data=f"post"),
],
[
telegram.InlineKeyboardButton("❌ Отмена", callback_data=f"nope"),
]
])
)
else:
# looks like a text post
if len(started_post["text"] or "") < 120:
return "Напиши или форвардни мне нормальный пост или ссылку!"
send_telegram_message(
chat=Chat(id=update.effective_chat.id),
text=f"Хотите поделиться этим в Клубе? Как будем постить?",
reply_markup=telegram.InlineKeyboardMarkup([
[
telegram.InlineKeyboardButton("📝 Как пост", callback_data=f"post"),
telegram.InlineKeyboardButton("❔ Вопросом", callback_data=f"question"),
],
[
telegram.InlineKeyboardButton("❌ Отмена", callback_data=f"nope"),
]
])
)
def continue_posting(update: Update, started_post: dict, user: User):
if update.callback_query:
if update.callback_query.data == "nope":
# cancel posting
cached_post_delete(update.effective_user.id)
return "Ок, забыли 👌"
elif update.callback_query.data in {"post", "link", "question"}:
# ask for title
started_post["type"] = update.callback_query.data
cached_post_set(update.effective_user.id, started_post)
return f"Отлично. Теперь надо придумать заголовок, чтобы всем было понятно о чем это. " \
f"Подумайте и пришлите его следующим сообщением 👇"
elif update.callback_query.data == "go":
# go-go-go, post the post
FormClass = POST_TYPE_MAP.get(started_post["type"]) or PostTextForm
form = FormClass(started_post)
if not form.is_valid():
return f"🤦‍♂️ Что-то пошло не так. Пришлите нам багрепорт. " \
f"Вот ошибка:\n```{str(form.errors)}```"
if Post.check_duplicate(user=user, title=form.cleaned_data["title"]):
return "🤔 Выглядит как дубликат вашего прошлого поста. " \
"Проверьте всё ли в порядке и пришлите ниже другой заголовок 👇"
is_ok = Post.check_rate_limits(user)
if not is_ok:
return "🙅‍♂️ Извините, вы сегодня запостили слишком много постов. Попробуйте попозже"
post = form.save(commit=False)
post.author = user
post.type = started_post["type"]
post.meta = {"telegram": update.to_json()}
post.save()
post_url = settings.APP_HOST + reverse("show_post", kwargs={
"post_type": post.type,
"post_slug": post.slug
})
cached_post_delete(update.effective_user.id)
return f"Запостили 🚀🚀🚀\n\n{post_url}"
if update.message:
started_post["title"] = str(update.message.text or update.message.caption or "").strip()[:128]
if len(started_post["title"]) < 7:
send_telegram_message(
chat=Chat(id=update.effective_chat.id),
text=f"Какой-то короткий заголовок. Пришлите другой, подлинее",
reply_markup=telegram.InlineKeyboardMarkup([
[
telegram.InlineKeyboardButton("❌ Отменить всё", callback_data=f"nope"),
]
])
)
return
cached_post_set(update.effective_user.id, started_post)
emoji = Post.TYPE_TO_EMOJI.get(started_post["type"]) or "🔥"
send_telegram_message(
chat=Chat(id=update.effective_chat.id),
text=f"Заголовок принят. Теперь пост выглядит как-то так:\n\n"
f"{emoji} *{started_post['title']}*\n\n"
f"{started_post['text'] or ''}\n\n"
f"{started_post['url'] or ''}\n\n"
f"*Будем постить?* (после публикации его можно будет подредактировать на сайте)",
reply_markup=telegram.InlineKeyboardMarkup([
[
telegram.InlineKeyboardButton("✅ Поехали", callback_data=f"go"),
telegram.InlineKeyboardButton("❌ Отмена", callback_data=f"nope"),
],
])
)
def cached_post_get(telegram_user_id):
return cache.get(BOT_USER_POST_KEY.format(telegram_user_id))
def cached_post_set(telegram_user_id, data):
return cache.set(
BOT_USER_POST_KEY.format(telegram_user_id),
data,
BOT_USER_POST_TTL
)
def cached_post_delete(telegram_user_id):
return cache.delete(BOT_USER_POST_KEY.format(telegram_user_id))

68
bot/handlers/replies.py Normal file
View File

@ -0,0 +1,68 @@
import logging
import re
from django.conf import settings
from django.urls import reverse
from telegram import Update
from bot.common import send_telegram_message, Chat
from comments.models import Comment
from users.models import User
COMMENT_URL_RE = re.compile(r"https?:[/|.|\w|\s|-]*/post/.+?/comment/([a-fA-F0-9\-]+)/")
log = logging.getLogger(__name__)
def process_comment_reply(update: Update):
if not update.message.reply_to_message:
return
user = User.objects.filter(telegram_id=update.effective_user.id).first()
if not user:
send_telegram_message(
chat=Chat(id=update.effective_user.id),
text=f"😐 Извините, мы не знакомы. Привяжите свой аккаунт в профиле на https://vas3k.club"
)
return
comment_url_entity = [
entity["url"] for entity in update.message.reply_to_message.entities
if entity["type"] == "text_link" and COMMENT_URL_RE.match(entity["url"])
]
if not comment_url_entity:
log.info(f"Comment url not found in: {update.message.reply_to_message.entities}")
return
reply_to_id = COMMENT_URL_RE.match(comment_url_entity[0]).group(1)
reply = Comment.objects.filter(id=reply_to_id).first()
if not reply:
log.info(f"Reply not found: {reply_to_id}")
return
is_ok = Comment.check_rate_limits(user)
if not is_ok:
send_telegram_message(
chat=Chat(id=update.effective_chat.id),
text=f"🙅‍♂️ Извините, вы комментировали слишком часто и достигли дневного лимита"
)
return
comment = Comment.objects.create(
author=user,
post=reply.post,
reply_to=Comment.find_top_comment(reply),
text=update.message.text,
useragent="TelegramBot (like TwitterBot)",
metadata={
"telegram": update.to_dict()
}
)
new_comment_url = settings.APP_HOST + reverse("show_comment", kwargs={
"post_slug": comment.post.slug,
"comment_id": comment.id
})
send_telegram_message(
chat=Chat(id=update.effective_chat.id),
text=f"➜ [Отвечено]({new_comment_url}) 👍"
)

View File

View File

View File

@ -0,0 +1,21 @@
import logging
from django.conf import settings
from django.core.management import BaseCommand
from django.urls import reverse
from bot.bot import bot
log = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Set telegram webhook"
def handle(self, *args, **options):
# setWebhook for the bot
if settings.TELEGRAM_TOKEN:
webhook_uri = reverse("webhook_telegram", kwargs={"token": settings.TELEGRAM_TOKEN})
bot.set_webhook("https://vas3k.club" + webhook_uri)
self.stdout.write("Done 🥙")

View File

@ -0,0 +1,7 @@
{% if post.emoji %}{{ post.emoji }} {% endif %}<b>{% if post.prefix %}{{ post.prefix }} {% endif %}{{ post.title }} {% if post.topic %} [{{ post.topic.name }}]{% endif %}</b> от {{ post.author.slug }}
{% if post.url %}<b>Ссылка:</b> {{ post.url }}
{% endif %}
{% load posts %}{% render_plain post 350 %}
➜ {{ settings.APP_HOST }}{% url "show_post" post.type post.slug %}

View File

@ -0,0 +1,5 @@
💬 <b>Новый коммент к вашему посту «<a href="{{ settings.APP_HOST }}{% url "show_post" comment.post.type comment.post.slug %}">{{ comment.post.title }}</a>»</b>
{% load posts %}<b>{{ comment.author.slug }}:</b> {% render_plain comment %}
<a href="{{ settings.APP_HOST }}{% url "show_comment" comment.post.slug comment.id %}">Посмотреть ➜</a>

View File

@ -0,0 +1,5 @@
💬 <b>Новый реплай к вашему комментарию под постом «<a href="{{ settings.APP_HOST }}{% url "show_post" comment.post.type comment.post.slug %}">{{ comment.post.title }}</a>»</b>
{% load posts %}<b>{{ comment.author.slug }}:</b> {% render_plain comment %}
<a href="{{ settings.APP_HOST }}{% url "show_comment" comment.post.slug comment.id %}">Посмотреть ➜</a>

View File

@ -0,0 +1,7 @@
💬 <b>Новый {% if not comment.reply_to_id %}коммент{% else %}реплай{% endif %} к посту «<a href="{{ settings.APP_HOST }}{% url "show_post" comment.post.type comment.post.slug %}">{{ comment.post.title }}</a>»</b>
<b>Автор:</b> {{ comment.author.slug }}
{% load posts %}{% render_plain comment 350 %}
<a href="{{ settings.APP_HOST }}{% url "show_comment" comment.post.slug comment.id %}">Посмотреть ➜</a>

View File

@ -0,0 +1,8 @@
🔥 <b>NEW: {% if post.prefix %}{{ post.prefix }} {% endif %}{{ post.title }}{% if post.topic %} [{{ post.topic.name }}]{% endif %}</b>
<b>Автор:</b> {{ post.author.slug }} {% if post.author.telegram_data %}(tg: @{{ post.author.telegram_data.username }}){% endif %}
{% if post.url %}<b>Ссылка:</b> {{ post.url }}{% endif %}
{% load posts %}{% render_plain post 350 %}
{{ settings.APP_HOST }}{% url "show_post" post.type post.slug %}

View File

@ -0,0 +1,3 @@
👍 Ваш пост «<a href="{{ settings.APP_HOST }}{% url "show_post" post.type post.slug %}">{{ post.title }}</a>» понравился модераторам Клуба.
Теперь он попадёт в клубные подборки и может даже на канал. Продолжайте быть клёвым!

View File

@ -0,0 +1,8 @@
👎 К сожалению, модераторы перенесли ваш пост «<a href="{{ settings.APP_HOST }}{% url "show_post" post.type post.slug %}">{{ post.title }}</a>» обратно в черновики.
Он не прошел наш контроль качества и требует доработки. Вот популярные причины почему так бывает:
- Пост недооформлен или, наоборот, использует слишком много разных стилей
- Информация слишком поверхностна, а мы ждём настоящих честных инсайдов
- Контент не несёт пользы Клубу или выглядит рекламным
Поработайте над ним еще. Вы можете лучше!

121
bot/views.py Normal file
View File

@ -0,0 +1,121 @@
import hashlib
import hmac
import json
import logging
from django.conf import settings
from django.core.cache import cache
from django.http import HttpResponse, Http404
from django.shortcuts import render
from telegram import Update
from auth.helpers import auth_required
from bot.bot import bot
from bot.handlers.moderator import process_moderator_actions
from bot.handlers.personal import process_personal_chat_updates
from bot.handlers.replies import process_comment_reply
from club.exceptions import AccessDenied
from common.request import ajax_request
from users.models import User
log = logging.getLogger(__name__)
def webhook_telegram(request, token):
if not bot:
return HttpResponse("Not configured", status=500)
if token != settings.TELEGRAM_TOKEN:
return HttpResponse("Go away", status=400)
# try to get the json body or poll the latest updates
if request.body:
updates = [Update.de_json(json.loads(request.body), bot)]
else:
# useful in development
updates = bot.get_updates()
for update in updates:
log.info(f"Update: {update.to_dict()}")
if update.effective_chat:
# reply to a comment
if update.message and update.message.reply_to_message \
and update.message.reply_to_message.text \
and update.message.reply_to_message.text.startswith("💬"):
if is_club_user(update.effective_user.id):
process_comment_reply(update)
# admin chat
elif update.effective_chat and \
str(update.effective_chat.id) == settings.TELEGRAM_ADMIN_CHAT_ID:
if update.callback_query:
process_moderator_actions(update)
# user personal chats
elif update.effective_chat and update.effective_user \
and update.effective_chat.id == update.effective_user.id:
if is_club_user(update.effective_user.id):
process_personal_chat_updates(update)
return HttpResponse("OK")
def is_club_user(telegram_user_id):
club_users = cache.get("bot:telegram_user_ids")
if not club_users:
club_users = User.objects.filter(telegram_id__isnull=False).values_list("telegram_id", flat=True)
cache.set("bot:telegram_user_ids", list(club_users), 5 * 60)
return str(telegram_user_id) in set(club_users)
@auth_required
@ajax_request
def link_telegram(request):
if not request.body:
raise Http404()
if request.method == "POST":
data = json.loads(request.body)
if not data.get("id") or not data.get("hash"):
return render(request, "error.html", {
"title": "Что-то пошло не так",
"message": "Попробуйте авторизоваться снова.",
})
if not is_valid_telegram_data(data, settings.TELEGRAM_TOKEN):
raise AccessDenied(title="Подпись сообщения не совпадает")
request.me.telegram_id = data["id"]
request.me.telegram_data = data
request.me.save()
full_name = str(request.me.telegram_data.get("first_name") or "") \
+ str(request.me.telegram_data.get("last_name") or "")
return {
"status": "success",
"telegram": {
"id": request.me.telegram_id,
"username": request.me.telegram_data.get("username") or full_name,
"full_name": full_name,
}
}
return {"status": "nope"}
def is_valid_telegram_data(data, bot_token):
data = dict(data)
check_hash = data.pop('hash')
check_list = ['{}={}'.format(k, v) for k, v in data.items()]
check_string = '\n'.join(sorted(check_list))
secret_key = hashlib.sha256(bot_token.encode()).digest()
hmac_hash = hmac.new(
secret_key,
check_string.encode(),
hashlib.sha256,
).hexdigest()
return hmac_hash == check_hash

0
club/__init__.py Normal file
View File

16
club/asgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
ASGI config for club 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/3.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "club.settings")
application = get_asgi_application()

View File

@ -0,0 +1,5 @@
from django.conf import settings
def settings_processor(request):
return {"settings": settings}

41
club/exceptions.py Normal file
View File

@ -0,0 +1,41 @@
class ClubException(Exception):
default_code = "error"
default_title = "Что-то пошло не так"
default_message = "Никто не знает что произошло :("
def __init__(self, code=None, title=None, message=None, data=None):
self.code = code or self.default_code
self.title = title or self.default_title
self.message = message or self.default_message
self.data = data or {}
class NotFound(ClubException):
default_code = "not-found"
default_title = "Не найдено"
default_message = ""
class AccessDenied(ClubException):
default_code = "access-forbidden"
default_title = "Вам сюда нельзя"
default_message = "Атата"
class RateLimitException(ClubException):
default_code = "rate-limit"
default_title = "Вы создали слишком много постов или комментов сегодня"
default_message = "Пожалуйста, остановитесь"
class ContentDuplicated(ClubException):
default_code = "duplicated-content"
default_title = "Обнаружен дубликат!"
default_message = "Кажется, вы пытаетесь опубликовать то же самое повторно. " \
"Проверьте всё ли в порядке."
class URLParsingException(ClubException):
default_code = "url-parser-exception"
default_title = "Не удалось распарсить URL"
default_message = ""

33
club/middleware.py Normal file
View File

@ -0,0 +1,33 @@
from django.shortcuts import render
from auth.helpers import authorized_user_with_session
from club.exceptions import ClubException
def me(get_response):
def middleware(request):
request.me, request.my_session = authorized_user_with_session(request)
response = get_response(request)
return response
return middleware
class ExceptionMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
return response
def process_exception(self, request, exception):
if isinstance(exception, ClubException):
return render(
request,
"error.html",
{"title": exception.title, "message": exception.message},
status=400,
)

185
club/settings.py Normal file
View File

@ -0,0 +1,185 @@
import os
import random
from datetime import timedelta, datetime
import sentry_sdk
from dotenv import load_dotenv
from sentry_sdk.integrations.django import DjangoIntegration
load_dotenv()
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SECRET_KEY = os.getenv("SECRET_KEY") or "wow so secret"
DEBUG = (os.getenv("DEBUG") == "true") # SECURITY WARNING: don"t run with debug turned on in production!
ALLOWED_HOSTS = ["127.0.0.1", "localhost", "0.0.0.0", "vas3k.ru", "vas3k.club"]
INTERNAL_IPS = ["127.0.0.1"]
INSTALLED_APPS = [
"django.contrib.staticfiles",
"django.contrib.humanize",
"club",
"auth.apps.AuthConfig",
"comments.apps.CommentsConfig",
"landing.apps.LandingConfig",
"payments.apps.PaymentsConfig",
"posts.apps.PostsConfig",
"users.apps.UsersConfig",
"notifications.apps.NotificationsConfig",
"bot.apps.BotConfig",
"simple_history",
"django_q",
]
MIDDLEWARE = [
"django.middleware.common.CommonMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"club.middleware.me",
"club.middleware.ExceptionMiddleware",
]
ROOT_URLCONF = "club.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "frontend/html")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"club.context_processors.settings_processor",
"auth.context_processors.users.me",
"posts.context_processors.topics.topics",
]
},
}
]
WSGI_APPLICATION = "club.wsgi.application"
# Database
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": os.getenv("POSTGRES_DB") or "vas3k_club",
"USER": os.getenv("POSTGRES_USER") or "postgres",
"PASSWORD": os.getenv("POSTGRES_PASSWORD") or "",
"HOST": os.getenv("POSTGRES_HOST") or "localhost",
"PORT": os.getenv("POSTGRES_PORT") or 5432,
}
}
# Internationalization
# https://docs.djangoproject.com/en/3.0/topics/i18n/
LANGUAGE_CODE = "ru"
TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
USE_TZ = False
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.0/howto/static-files/
STATIC_URL = "/static/"
STATICFILES_DIRS = [os.path.join(BASE_DIR, "frontend/static")]
# Task queue (django-q)
REDIS_HOST = os.getenv("REDIS_HOST") or "localhost"
REDIS_PORT = os.getenv("REDIS_PORT") or 6379
Q_CLUSTER = {
"name": "vas3k_club",
"workers": 4,
"recycle": 500,
"timeout": 30,
"compress": True,
"save_limit": 250,
"queue_limit": 5000,
"redis": {
"host": REDIS_HOST,
"port": REDIS_PORT,
"db": os.getenv("REDIS_DB") or 0
}
}
# Redis cache
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": f"redis://{REDIS_HOST}:{REDIS_PORT}/1",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
}
}
}
# App
APP_HOST = os.environ.get("APP_HOST") or "http://127.0.0.1:8000"
APP_NAME = "Вастрик.Клуб"
APP_DESCRIPTION = "Всё интересное происходит за закрытыми дверями"
LAUNCH_DATE = datetime(2020, 4, 13)
DEFAULT_PAGE_SIZE = 100
SENTRY_DSN = os.getenv("SENTRY_DSN")
PATREON_AUTH_URL = "https://www.patreon.com/oauth2/authorize"
PATREON_TOKEN_URL = "https://www.patreon.com/api/oauth2/token"
PATREON_USER_URL = "https://www.patreon.com/api/oauth2/v2/identity"
PATREON_CLIENT_ID = os.getenv("PATREON_CLIENT_ID")
PATREON_CLIENT_SECRET = os.getenv("PATREON_CLIENT_SECRET")
PATREON_REDIRECT_URL = f"{APP_HOST}/auth/patreon_callback/"
PATREON_SCOPE = "identity identity[email]"
PATREON_GOD_IDS = ["8724543"]
JWT_SECRET = os.getenv("JWT_SECRET")
JWT_ALGORITHM = "HS256"
JWT_EXP_TIMEDELTA = timedelta(days=120)
MAILGUN_API_URI = "https://api.eu.mailgun.net/v3/mailgun.vas3k.club"
MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY")
MAILGUN_EMAIL_FROM = "Вастрик.Клуб <club@vas3k.club>"
MEDIA_UPLOAD_URL = "https://i.vas3k.club/upload/"
MEDIA_UPLOAD_CODE = os.getenv("MEDIA_UPLOAD_CODE")
VIDEO_EXTENSIONS = {"mp4", "mov", "webm"}
IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif"}
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN")
TELEGRAM_BOT_URL = os.getenv("TELEGRAM_BOT_URL")
TELEGRAM_ADMIN_CHAT_ID = os.getenv("TELEGRAM_ADMIN_CHAT_ID")
TELEGRAM_ONLINE_CHANNEL_URL = os.getenv("TELEGRAM_ONLINE_CHANNEL_URL")
TELEGRAM_ONLINE_CHANNEL_ID = os.getenv("TELEGRAM_ONLINE_CHANNEL_ID")
TELEGRAM_CLUB_CHAT_URL = os.getenv("TELEGRAM_CLUB_CHAT_URL")
TELEGRAM_CLUB_CHAT_ID = os.getenv("TELEGRAM_CLUB_CHAT_ID")
COMMENT_EDIT_TIMEDELTA = timedelta(hours=3)
RATE_LIMIT_POSTS_PER_DAY = 10
RATE_LIMIT_COMMENTS_PER_DAY = 200
POST_VIEW_COOLDOWN_PERIOD = timedelta(days=1)
CSS_HASH = str(random.random())
if SENTRY_DSN and not DEBUG:
# activate sentry on production
sentry_sdk.init(dsn=SENTRY_DSN, integrations=[DjangoIntegration()])
Q_CLUSTER["error_reporter"] = {
"sentry": {
"dsn": SENTRY_DSN
}
}
if DEBUG:
INSTALLED_APPS += ["debug_toolbar"]
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE

70
club/urls.py Normal file
View File

@ -0,0 +1,70 @@
from django.conf import settings
from django.urls import path, include
from auth.helpers import auth_switch
from auth.views.auth import login, logout
from auth.views.patreon import patreon_login, patreon_oauth_callback
from bot.views import webhook_telegram, link_telegram
from comments.views import create_comment, edit_comment, delete_comment, show_comment, upvote_comment, pin_comment
from landing.views import landing, docs, god_settings
from notifications.views import weekly_digest, email_unsubscribe, email_confirm
from payments.views import membership_expired
from posts.views import compose, compose_type, show_post, feed, upvote_post, edit_post, admin_post, announce_post
from users.views import profile, edit_profile, on_review, banned, rejected, intro, toggle_tag, \
add_expertise, admin_profile, delete_expertise
urlpatterns = [
path("", auth_switch(landing, feed), name="index"),
path("auth/login/", login, name="login"),
path("auth/logout/", logout, name="logout"),
path("auth/patreon/", patreon_login, name="patreon_login"),
path("auth/patreon_callback/", patreon_oauth_callback, name="patreon_oauth_callback"),
path("monies/membership_expired/", membership_expired, name="membership_expired"),
path("user/<slug:user_slug>/", profile, name="profile"),
path("user/<slug:user_slug>/edit/", edit_profile, name="edit_profile"),
path("user/<slug:user_slug>/admin/", admin_profile, name="admin_profile"),
path("intro/", intro, name="intro"),
path("profile/tag/<slug:tag_code>/toggle/", toggle_tag, name="toggle_tag"),
path("profile/expertise/add/", add_expertise, name="add_expertise"),
path("profile/expertise/<slug:expertise>/delete/", delete_expertise, name="delete_expertise"),
path("profile/on_review/", on_review, name="on_review"),
path("profile/rejected/", rejected, name="rejected"),
path("profile/banned/", banned, name="banned"),
path("feed/type/<slug:post_type>/", feed, name="feed_type"),
path("feed/topic/<slug:topic_slug>/", feed, name="feed_topic"),
path("create/", compose, name="compose"),
path("create/<slug:post_type>/", compose_type, name="compose_type"),
path("post/<slug:post_slug>/edit/", edit_post, name="edit_post"),
path("post/<slug:post_slug>/upvote/", upvote_post, name="upvote_post"),
path("post/<slug:post_slug>/admin/", admin_post, name="admin_post"),
path("post/<slug:post_slug>/announce/", announce_post, name="announce_post"),
path("post/<slug:post_slug>/comment/create/", create_comment, name="create_comment"),
path("post/<slug:post_slug>/comment/<uuid:comment_id>/", show_comment, name="show_comment",),
path("comment/<uuid:comment_id>/upvote/", upvote_comment, name="upvote_comment"),
path("comment/<uuid:comment_id>/edit/", edit_comment, name="edit_comment"),
path("comment/<uuid:comment_id>/pin/", pin_comment, name="pin_comment"),
path("comment/<uuid:comment_id>/delete/", delete_comment, name="delete_comment"),
path("telegram/link/", link_telegram, name="link_telegram"),
path("telegram/webhook/<str:token>/", webhook_telegram, name="webhook_telegram"),
path("notifications/confirm/<str:user_id>/<str:secret>/", email_confirm, name="email_confirm"),
path("notifications/unsubscribe/<str:user_id>/<str:secret>/", email_unsubscribe, name="email_unsubscribe"),
path("notifications/renderer/digest/weekly/", weekly_digest, name="render_weekly_digest"),
path("godmode/", god_settings, name="god_settings"),
path("docs/<slug:doc_slug>/", docs, name="docs"),
# keep this guy at the bottom
path("<slug:post_type>/<slug:post_slug>/", show_post, name="show_post"),
]
if settings.DEBUG:
import debug_toolbar
urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns

16
club/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for club 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/3.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "club.settings")
application = get_wsgi_application()

0
comments/__init__.py Normal file
View File

5
comments/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class CommentsConfig(AppConfig):
name = "comments"

42
comments/forms.py Normal file
View File

@ -0,0 +1,42 @@
from django import forms
from comments.models import Comment
class CommentForm(forms.ModelForm):
text = forms.CharField(
label="Текст комментария",
required=True,
max_length=10000,
widget=forms.Textarea(
attrs={
"maxlength": 10000,
"placeholder": "Напишите комментарий...",
"class": "markdown-editor-invisible",
}
),
)
reply_to_id = forms.UUIDField(label="Ответ на", required=False)
class Meta:
model = Comment
fields = ["text"]
# class ReplyForm(forms.ModelForm):
# text = forms.CharField(
# label="Ответ",
# required=True,
# max_length=10000,
# widget=forms.Textarea(
# attrs={
# "maxlength": 10000,
# "placeholder": "Напишите ответ...",
# }
# ),
# )
# reply_to_id = forms.UUIDField(label="Ответ на", required=True)
#
# class Meta:
# model = Comment
# fields = ["text"]

View File

@ -0,0 +1,87 @@
# Generated by Django 3.0.4 on 2020-04-08 10:09
import uuid
import django.contrib.postgres.fields.jsonb
import django.db.models.deletion
import simple_history.models
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('users', '0001_initial'),
('posts', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Comment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('text', models.TextField()),
('html', models.TextField(null=True)),
('url', models.URLField(null=True)),
('metadata', django.contrib.postgres.fields.jsonb.JSONField(null=True)),
('ipaddress', models.GenericIPAddressField()),
('useragent', models.CharField(max_length=512, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('upvotes', models.IntegerField(db_index=True, default=0)),
('is_visible', models.BooleanField(default=True)),
('is_deleted', models.BooleanField(default=False)),
('is_pinned', models.BooleanField(default=False)),
('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comments', to='users.User')),
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='posts.Post')),
('reply_to', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='comments.Comment')),
],
options={
'db_table': 'comments',
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='HistoricalComment',
fields=[
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)),
('text', models.TextField()),
('url', models.URLField(null=True)),
('metadata', django.contrib.postgres.fields.jsonb.JSONField(null=True)),
('is_visible', models.BooleanField(default=True)),
('is_deleted', models.BooleanField(default=False)),
('is_pinned', models.BooleanField(default=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('author', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='users.User')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='users.User')),
('post', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='posts.Post')),
('reply_to', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='comments.Comment')),
],
options={
'verbose_name': 'historical comment',
'db_table': 'comments_history',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': 'history_date',
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='CommentVote',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='comments.Comment')),
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_votes', to='posts.Post')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comment_votes', to='users.User')),
],
options={
'db_table': 'comment_votes',
'unique_together': {('user', 'comment')},
},
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.0.4 on 2020-04-16 09:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comments', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='comment',
name='deleted_by',
field=models.UUIDField(null=True),
),
migrations.AddField(
model_name='historicalcomment',
name='deleted_by',
field=models.UUIDField(null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.4 on 2020-04-20 19:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comments', '0002_auto_20200416_0937'),
]
operations = [
migrations.AlterField(
model_name='comment',
name='ipaddress',
field=models.GenericIPAddressField(null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.0.4 on 2020-04-21 07:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comments', '0003_auto_20200420_1910'),
]
operations = [
migrations.AddField(
model_name='commentvote',
name='ipaddress',
field=models.GenericIPAddressField(null=True),
),
migrations.AddField(
model_name='commentvote',
name='useragent',
field=models.CharField(max_length=512, null=True),
),
]

View File

167
comments/models.py Normal file
View File

@ -0,0 +1,167 @@
from datetime import datetime, timedelta
from uuid import uuid4
from django.conf import settings
from django.contrib.postgres.fields import JSONField
from django.db import models
from django.db.models import F
from simple_history.models import HistoricalRecords
from club.exceptions import NotFound
from common.request import parse_ip_address, parse_useragent
from posts.models import Post
from users.models import User
class Comment(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
author = models.ForeignKey(User, related_name="comments", null=True, on_delete=models.SET_NULL)
post = models.ForeignKey(Post, related_name="comments", on_delete=models.CASCADE)
reply_to = models.ForeignKey("self", related_name="replies", null=True, on_delete=models.CASCADE)
text = models.TextField(null=False)
html = models.TextField(null=True)
url = models.URLField(null=True)
metadata = JSONField(null=True)
ipaddress = models.GenericIPAddressField(null=True)
useragent = models.CharField(max_length=512, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
upvotes = models.IntegerField(default=0, db_index=True)
is_visible = models.BooleanField(default=True)
is_deleted = models.BooleanField(default=False)
is_pinned = models.BooleanField(default=False)
deleted_by = models.UUIDField(null=True)
history = HistoricalRecords(
user_model=User,
table_name="comments_history",
excluded_fields=[
"html",
"ipaddress",
"useragent",
"created_at",
"updated_at",
"upvotes",
],
)
class Meta:
db_table = "comments"
ordering = ["created_at"]
def save(self, *args, **kwargs):
if self.reply_to:
self.reply_to = self.find_top_comment(self.reply_to)
self.updated_at = datetime.utcnow()
return super().save(*args, **kwargs)
def delete(self, deleted_by=None, *args, **kwargs):
self.is_deleted = True
self.deleted_by = deleted_by.id
self.save()
def undelete(self, *args, **kwargs):
self.is_deleted = False
self.deleted_by = None
self.save()
def increment_vote_count(self):
return Comment.objects.filter(id=self.id).update(upvotes=F("upvotes") + 1)
@property
def is_editable(self):
return self.created_at >= datetime.utcnow() - settings.COMMENT_EDIT_TIMEDELTA
@classmethod
def visible_objects(cls):
return cls.objects\
.filter(is_visible=True)\
.select_related("author", "reply_to")
@classmethod
def objects_for_user(cls, user):
return cls.visible_objects().extra({
"is_voted": "select 1 from comment_votes "
"where comment_votes.comment_id = comments.id "
f"and comment_votes.user_id = '{user.id}'"
})
@classmethod
def update_post_counters(cls, post):
post.comment_count = Comment.visible_objects().filter(post=post, is_deleted=False).count()
post.last_activity_at = datetime.utcnow()
post.save()
@classmethod
def find_top_comment(cls, comment):
if not comment.reply_to:
return comment
depth = 10
while depth > 0:
depth -= 1
parent = comment.reply_to
if not parent.reply_to:
return parent
raise NotFound(title="Родительский коммент не найден")
@classmethod
def check_rate_limits(cls, user):
if user.is_moderator:
return True
day_comment_count = Comment.visible_objects()\
.filter(
author=user, created_at__gte=datetime.utcnow() - timedelta(hours=24)
)\
.count()
return day_comment_count < settings.RATE_LIMIT_COMMENTS_PER_DAY
class CommentVote(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
user = models.ForeignKey(User, related_name="comment_votes", db_index=True, null=True, on_delete=models.SET_NULL)
post = models.ForeignKey(Post, related_name="comment_votes", db_index=True, on_delete=models.CASCADE)
comment = models.ForeignKey(Comment, related_name="votes", db_index=True, on_delete=models.CASCADE)
ipaddress = models.GenericIPAddressField(null=True)
useragent = models.CharField(max_length=512, null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "comment_votes"
unique_together = [["user", "comment"]]
@classmethod
def upvote(cls, request, user, comment):
if user.id == comment.author_id:
return None, False
post_vote, is_vote_created = CommentVote.objects.get_or_create(
user=user,
comment=comment,
defaults=dict(
post=comment.post,
ipaddress=parse_ip_address(request),
useragent=parse_useragent(request),
)
)
if is_vote_created:
comment.increment_vote_count()
comment.author.increment_vote_count()
return post_vote, is_vote_created

View File

View File

@ -0,0 +1,59 @@
from collections import namedtuple
from django import template
from django.utils.safestring import mark_safe
from club import settings
from common.markdown.markdown import markdown_text
register = template.Library()
TreeComment = namedtuple("TreeComment", ["comment", "replies"])
@register.filter()
def comment_tree(comments, ordering="top"):
comments = list(comments) # in case if it's a queryset
tree = []
for comment in comments:
if not comment.reply_to:
# take the high level comment and find all replies for it
tree_comment = TreeComment(
comment=comment,
replies=[c for c in comments if c.reply_to_id == comment.id],
)
tree.append(tree_comment)
if ordering == "top":
# sort by number of upvotes
tree = sorted(tree, key=lambda c: c.comment.upvotes, reverse=True)
# move pinned comments to the top
tree = sorted(tree, key=lambda c: c.comment.is_pinned, reverse=True)
return tree
@register.simple_tag(takes_context=True)
def render_comment(context, comment):
if comment.is_deleted:
if comment.deleted_by == comment.author_id:
by_who = " его автором"
elif comment.deleted_by == comment.post.author_id:
by_who = " автором поста"
else:
by_who = " модератором"
return mark_safe(
f"""<p class="comment-text-deleted">😱 Комментарий удален{by_who}...</p>"""
)
if not comment.html or settings.DEBUG:
new_html = markdown_text(comment.text)
if new_html != comment.html:
# to not flood into history
comment.html = new_html
comment.save()
return mark_safe(comment.html or "")

185
comments/views.py Normal file
View File

@ -0,0 +1,185 @@
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from auth.helpers import auth_required
from club.exceptions import AccessDenied, RateLimitException
from comments.forms import CommentForm
from comments.models import Comment, CommentVote
from common.request import parse_ip_address, parse_useragent, ajax_request
from posts.models import Post, PostView
@auth_required
def create_comment(request, post_slug):
post = get_object_or_404(Post, slug=post_slug)
if not post.is_commentable and not request.me.is_moderator:
raise AccessDenied(title="Комментарии к этому посту закрыты")
if request.method == "POST":
form = CommentForm(request.POST)
if form.is_valid():
is_ok = Comment.check_rate_limits(request.me)
if not is_ok:
raise RateLimitException(
title="🙅‍♂️ Вы комментируете слишком часто",
message="Подождите немного, вы достигли нашего лимита на комментарии в день. "
"Можете написать нам в саппорт, пожаловаться об этом."
)
comment = form.save(commit=False)
comment.post = post
if not comment.author:
comment.author = request.me
comment.ipaddress = parse_ip_address(request)
comment.useragent = parse_useragent(request)
if form.cleaned_data["reply_to_id"]:
# stupid django can't do that from forms
comment.reply_to_id = form.cleaned_data["reply_to_id"]
comment.save()
# update the shitload of counters :)
request.me.update_last_activity()
Comment.update_post_counters(post)
PostView.increment_unread_comments(post)
PostView.create_or_update(
request=request,
user=request.me,
post=post,
)
return redirect("show_comment", post.slug, comment.id)
else:
return render(request, "error.html", {
"title": "Какая-то ошибка при публикации комментария 🤷‍♂️",
"message": f"Не знаем что происходит, но мы уже фиксим. "
f"Мы сохранили ваш коммент чтобы вы не потеряли его:",
"data": form.cleaned_data.get("text")
})
return Http404()
def show_comment(request, post_slug, comment_id):
post = get_object_or_404(Post, slug=post_slug)
return redirect(
reverse("show_post", kwargs={"post_type": post.type, "post_slug": post.slug}) + f"#comment-{comment_id}"
)
@auth_required
def edit_comment(request, comment_id):
comment = get_object_or_404(Comment, id=comment_id)
if not request.me.is_moderator:
if comment.author != request.me:
raise AccessDenied()
if not comment.is_editable:
raise AccessDenied(title="Этот комментарий больше нельзя редактировать")
if not comment.post.is_visible or not comment.post.is_commentable:
raise AccessDenied(title="Комментарии к этому посту были закрыты")
post = comment.post
if request.method == "POST":
form = CommentForm(request.POST, instance=comment)
if form.is_valid():
comment = form.save(commit=False)
comment.is_deleted = False
comment.html = None # flush cache
comment.ipaddress = parse_ip_address(request)
comment.useragent = parse_useragent(request)
comment.save()
return redirect("show_comment", post.slug, comment.id)
else:
form = CommentForm(instance=comment)
return render(request, "comments/edit.html", {
"comment": comment,
"post": post,
"form": form
})
@auth_required
def delete_comment(request, comment_id):
comment = get_object_or_404(Comment, id=comment_id)
if not request.me.is_moderator:
# only comment author, post author or moderator can delete comments
if comment.author != request.me and request.me != comment.post.author:
raise AccessDenied(
title="Нельзя!",
message="Только автор комментария, поста или модератор может удалить комментарий"
)
if not comment.is_editable:
raise AccessDenied(
title="Время вышло",
message="Комментарий можно отредактировать или удалить только в первые несколько часов их жизни"
)
if not comment.post.is_visible:
raise AccessDenied(
title="Пост скрыт!",
message="Нельзя удалять комментарии к скрытому посту"
)
if not comment.is_deleted:
# delete comment
comment.delete(deleted_by=request.me)
else:
# undelete comment
if comment.deleted_by == request.me or request.me.is_moderator:
comment.undelete()
else:
raise AccessDenied(
title="Нельзя!",
message="Только тот, кто удалил комментарий, может его восстановить"
)
Comment.update_post_counters(comment.post)
return redirect("show_comment", comment.post.slug, comment.id)
@auth_required
def pin_comment(request, comment_id):
comment = get_object_or_404(Comment, id=comment_id)
if not request.me.is_moderator and comment.post.author != request.me:
raise AccessDenied(
title="Нельзя!",
message="Только автор поста или модератор может пинить посты"
)
comment.is_pinned = not comment.is_pinned # toggle
comment.save()
return redirect("show_comment", comment.post.slug, comment.id)
@auth_required
@ajax_request
def upvote_comment(request, comment_id):
if request.method != "POST":
raise Http404()
comment = get_object_or_404(Comment, id=comment_id)
_, is_created = CommentVote.upvote(
request=request,
user=request.me,
comment=comment,
)
return {
"comment": {
"upvotes": comment.upvotes + (1 if is_created else 0)
}
}

0
common/__init__.py Normal file
View File

0
common/data/__init__.py Normal file
View File

View File

@ -0,0 +1,32 @@
ACHIEVEMENTS = {
"1year": {
"title": "1 год",
"description": "Член клуба целый год!",
"icon": "/static/images/badges/year-1.png",
"style": "color: #FFF; background-color: #83d0c9;",
},
"2year": {
"title": "2 года",
"description": "Член клуба уже 2 года!",
"icon": "/static/images/badges/year-2.png",
"style": "color: #FFF; background-color: #65c3ba;",
},
"3year": {
"title": "3 года",
"description": "Член клуба уже 3 года!",
"icon": "/static/images/badges/year-1.png",
"style": "color: #FFF; background-color: #54b2a9;",
},
"cyberwar": {
"title": "За кибервойну",
"description": "Стать автором треда на 100+ комментариев",
"icon": "https://i.vas3k.ru/e85.jpg",
"style": "color: #FFF; background-color: #0392cf;",
},
"influencer": {
"title": "Инфлюенсер",
"description": "Написать пост и получить 100+ плюсиков",
"icon": "https://i.vas3k.ru/e85.jpg",
"style": "color: #FFF; background-color: #ee4035;",
},
}

29
common/data/colors.py Normal file
View File

@ -0,0 +1,29 @@
COOL_COLORS = [
"#66CDAA",
"#fe4a49",
"#fed766",
"#ee4035",
"#f37736",
"#7bc043",
"#0392cf",
"#d11141",
"#00b159",
"#7D3C98",
"#9B59B6",
"#34495E",
"#00aedb",
"#f37735",
"#95A5A6",
"#CD5C5C",
"#D4AC0D",
"#3E8FCD",
"#ffc425",
"#cc2a36",
"#4f372d",
"#00a0b0",
"#FE4365",
"#A7226E",
"#45ADA8",
"#2A363B",
"#229954",
]

265
common/data/countries.py Normal file
View File

@ -0,0 +1,265 @@
COUNTRIES = [
("Россия", "Россия"),
("Украина", "Украина"),
("Беларусь", "Беларусь"),
("Казахстан", "Казахстан"),
("Абхазия", "Абхазия"),
("Австралия", "Австралия"),
("Австрия", "Австрия"),
("Азербайджан", "Азербайджан"),
("Албания", "Албания"),
("Алжир", "Алжир"),
("Американское Самоа", "Американское Самоа"),
("Ангилья", "Ангилья"),
("Ангола", "Ангола"),
("Андорра", "Андорра"),
("Антарктида", "Антарктида"),
("Антигуа и Барбуда", "Антигуа и Барбуда"),
("Аргентина", "Аргентина"),
("Армения", "Армения"),
("Аруба", "Аруба"),
("Афганистан", "Афганистан"),
("Багамы", "Багамы"),
("Бангладеш", "Бангладеш"),
("Барбадос", "Барбадос"),
("Бахрейн", "Бахрейн"),
("Белиз", "Белиз"),
("Бельгия", "Бельгия"),
("Бенин", "Бенин"),
("Бермуды", "Бермуды"),
("Болгария", "Болгария"),
("Боливия, Многонациональное Государство", "Боливия, Многонациональное Государство"),
("Бонайре, Саба и Синт-Эстатиус", "Бонайре, Саба и Синт-Эстатиус"),
("Босния и Герцеговина", "Босния и Герцеговина"),
("Ботсвана", "Ботсвана"),
("Бразилия", "Бразилия"),
("Британская территория в Индийском океане", "Британская территория в Индийском океане"),
("Бруней-Даруссалам", "Бруней-Даруссалам"),
("Буркина-Фасо", "Буркина-Фасо"),
("Бурунди", "Бурунди"),
("Бутан", "Бутан"),
("Вануату", "Вануату"),
("Венгрия", "Венгрия"),
("Венесуэла Боливарианская Республика", "Венесуэла Боливарианская Республика"),
("Виргинские острова, Британские", "Виргинские острова, Британские"),
("Виргинские острова, США", "Виргинские острова, США"),
("Вьетнам", "Вьетнам"),
("Габон", "Габон"),
("Гаити", "Гаити"),
("Гайана", "Гайана"),
("Гамбия", "Гамбия"),
("Гана", "Гана"),
("Гваделупа", "Гваделупа"),
("Гватемала", "Гватемала"),
("Гвинея", "Гвинея"),
("Гвинея-Бисау", "Гвинея-Бисау"),
("Германия", "Германия"),
("Гернси", "Гернси"),
("Гибралтар", "Гибралтар"),
("Гондурас", "Гондурас"),
("Гонконг", "Гонконг"),
("Гренада", "Гренада"),
("Гренландия", "Гренландия"),
("Греция", "Греция"),
("Грузия", "Грузия"),
("Гуам", "Гуам"),
("Дания", "Дания"),
("Джерси", "Джерси"),
("Джибути", "Джибути"),
("Доминика", "Доминика"),
("Доминиканская Республика", "Доминиканская Республика"),
("Египет", "Египет"),
("Замбия", "Замбия"),
("Западная Сахара", "Западная Сахара"),
("Зимбабве", "Зимбабве"),
("Израиль", "Израиль"),
("Индия", "Индия"),
("Индонезия", "Индонезия"),
("Иордания", "Иордания"),
("Ирак", "Ирак"),
("Иран, Исламская Республика", "Иран, Исламская Республика"),
("Ирландия", "Ирландия"),
("Исландия", "Исландия"),
("Испания", "Испания"),
("Италия", "Италия"),
("Йемен", "Йемен"),
("Кабо-Верде", "Кабо-Верде"),
("Камбоджа", "Камбоджа"),
("Камерун", "Камерун"),
("Канада", "Канада"),
("Катар", "Катар"),
("Кения", "Кения"),
("Кипр", "Кипр"),
("Киргизия", "Киргизия"),
("Кирибати", "Кирибати"),
("Китай", "Китай"),
("Кокосовые (Килинг) острова", "Кокосовые (Килинг) острова"),
("Колумбия", "Колумбия"),
("Коморы", "Коморы"),
("Конго", "Конго"),
("Конго, Демократическая Республика", "Конго, Демократическая Республика"),
("Корея, Народно-Демократическая Республика", "Корея, Народно-Демократическая Республика"),
("Корея, Республика", "Корея, Республика"),
("Коста-Рика", "Коста-Рика"),
("Кот д'Ивуар", "Кот д'Ивуар"),
("Куба", "Куба"),
("Кувейт", "Кувейт"),
("Кюрасао", "Кюрасао"),
("Лаос", "Лаос"),
("Латвия", "Латвия"),
("Лесото", "Лесото"),
("Ливан", "Ливан"),
("Ливийская Арабская Джамахирия", "Ливийская Арабская Джамахирия"),
("Либерия", "Либерия"),
("Лихтенштейн", "Лихтенштейн"),
("Литва", "Литва"),
("Люксембург", "Люксембург"),
("Маврикий", "Маврикий"),
("Мавритания", "Мавритания"),
("Мадагаскар", "Мадагаскар"),
("Майотта", "Майотта"),
("Макао", "Макао"),
("Малави", "Малави"),
("Малайзия", "Малайзия"),
("Мали", "Мали"),
(
"Малые Тихоокеанские отдаленные острова Соединенных Штатов",
"Малые Тихоокеанские отдаленные острова Соединенных Штатов",
),
("Мальдивы", "Мальдивы"),
("Мальта", "Мальта"),
("Марокко", "Марокко"),
("Мартиника", "Мартиника"),
("Маршалловы острова", "Маршалловы острова"),
("Мексика", "Мексика"),
("Микронезия, Федеративные Штаты", "Микронезия, Федеративные Штаты"),
("Мозамбик", "Мозамбик"),
("Молдова, Республика", "Молдова, Республика"),
("Монако", "Монако"),
("Монголия", "Монголия"),
("Монтсеррат", "Монтсеррат"),
("Мьянма", "Мьянма"),
("Намибия", "Намибия"),
("Науру", "Науру"),
("Непал", "Непал"),
("Нигер", "Нигер"),
("Нигерия", "Нигерия"),
("Нидерланды", "Нидерланды"),
("Никарагуа", "Никарагуа"),
("Ниуэ", "Ниуэ"),
("Новая Зеландия", "Новая Зеландия"),
("Новая Каледония", "Новая Каледония"),
("Норвегия", "Норвегия"),
("Объединенные Арабские Эмираты", "Объединенные Арабские Эмираты"),
("Оман", "Оман"),
("Остров Буве", "Остров Буве"),
("Остров Мэн", "Остров Мэн"),
("Остров Норфолк", "Остров Норфолк"),
("Остров Рождества", "Остров Рождества"),
("Остров Херд и острова Макдональд", "Остров Херд и острова Макдональд"),
("Острова Кайман", "Острова Кайман"),
("Острова Кука", "Острова Кука"),
("Острова Теркс и Кайкос", "Острова Теркс и Кайкос"),
("Пакистан", "Пакистан"),
("Палау", "Палау"),
(
"Палестинская территория, оккупированная",
"Палестинская территория, оккупированная",
),
("Панама", "Панама"),
(
"Папский Престол (Государство &mdash; город Ватикан)",
"Папский Престол (Государство &mdash; город Ватикан)",
),
("Папуа-Новая Гвинея", "Папуа-Новая Гвинея"),
("Парагвай", "Парагвай"),
("Перу", "Перу"),
("Питкерн", "Питкерн"),
("Польша", "Польша"),
("Португалия", "Португалия"),
("Пуэрто-Рико", "Пуэрто-Рико"),
("Республика Македония", "Республика Македония"),
("Реюньон", "Реюньон"),
("Руанда", "Руанда"),
("Румыния", "Румыния"),
("Самоа", "Самоа"),
("Сан-Марино", "Сан-Марино"),
("Сан-Томе и Принсипи", "Сан-Томе и Принсипи"),
("Саудовская Аравия", "Саудовская Аравия"),
("Свазиленд", "Свазиленд"),
(
"Святая Елена, Остров вознесения, Тристан-да-Кунья",
"Святая Елена, Остров вознесения, Тристан-да-Кунья",
),
("Северные Марианские острова", "Северные Марианские острова"),
("Сен-Бартельми", "Сен-Бартельми"),
("Сен-Мартен", "Сен-Мартен"),
("Сенегал", "Сенегал"),
("Сент-Винсент и Гренадины", "Сент-Винсент и Гренадины"),
("Сент-Китс и Невис", "Сент-Китс и Невис"),
("Сент-Люсия", "Сент-Люсия"),
("Сент-Пьер и Микелон", "Сент-Пьер и Микелон"),
("Сербия", "Сербия"),
("Сейшелы", "Сейшелы"),
("Сингапур", "Сингапур"),
("Синт-Мартен", "Синт-Мартен"),
("Сирийская Арабская Республика", "Сирийская Арабская Республика"),
("Словакия", "Словакия"),
("Словения", "Словения"),
("Соединенное Королевство", "Соединенное Королевство"),
("Соединенные Штаты", "Соединенные Штаты"),
("Соломоновы острова", "Соломоновы острова"),
("Сомали", "Сомали"),
("Судан", "Судан"),
("Суринам", "Суринам"),
("Сьерра-Леоне", "Сьерра-Леоне"),
("Таджикистан", "Таджикистан"),
("Таиланд", "Таиланд"),
("Тайвань (Китай)", "Тайвань (Китай)"),
("Танзания, Объединенная Республика", "Танзания, Объединенная Республика"),
("Тимор-Лесте", "Тимор-Лесте"),
("Того", "Того"),
("Токелау", "Токелау"),
("Тонга", "Тонга"),
("Тринидад и Тобаго", "Тринидад и Тобаго"),
("Тувалу", "Тувалу"),
("Тунис", "Тунис"),
("Туркмения", "Туркмения"),
("Турция", "Турция"),
("Уганда", "Уганда"),
("Узбекистан", "Узбекистан"),
("Уоллис и Футуна", "Уоллис и Футуна"),
("Уругвай", "Уругвай"),
("Фарерские острова", "Фарерские острова"),
("Фиджи", "Фиджи"),
("Филиппины", "Филиппины"),
("Финляндия", "Финляндия"),
("Фолклендские острова (Мальвинские)", "Фолклендские острова (Мальвинские)"),
("Франция", "Франция"),
("Французская Гвиана", "Французская Гвиана"),
("Французская Полинезия", "Французская Полинезия"),
("Французские Южные территории", "Французские Южные территории"),
("Хорватия", "Хорватия"),
("Центрально-Африканская Республика", "Центрально-Африканская Республика"),
("Чад", "Чад"),
("Черногория", "Черногория"),
("Чешская Республика", "Чешская Республика"),
("Чили", "Чили"),
("Швейцария", "Швейцария"),
("Швеция", "Швеция"),
("Шпицберген и Ян Майен", "Шпицберген и Ян Майен"),
("Шри-Ланка", "Шри-Ланка"),
("Эквадор", "Эквадор"),
("Экваториальная Гвинея", "Экваториальная Гвинея"),
("Эландские острова", "Эландские острова"),
("Эль-Сальвадор", "Эль-Сальвадор"),
("Эритрея", "Эритрея"),
("Эстония", "Эстония"),
("Эфиопия", "Эфиопия"),
("Южная Африка", "Южная Африка"),
("Южная Джорджия и Южные Сандвичевы острова", "Южная Джорджия и Южные Сандвичевы острова"),
("Южная Осетия", "Южная Осетия"),
("Южный Судан", "Южный Судан"),
("Ямайка", "Ямайка"),
("Япония", "Япония"),
]

45
common/data/expertise.py Normal file
View File

@ -0,0 +1,45 @@
EXPERTISE = [
("Области", [
("frontend", "Фронтенд"),
("backend", "Бекенд"),
("mobile", "Мобильная разработка"),
("hardware", "Хардварь"),
("security", "Безопасность"),
("gamedev", "Геймдев"),
("hacking", "Хакинг"),
("pm", "Продакт-менеджмент"),
("lead", "Управление командами"),
("crypto", "Криптовалюты"),
("marketing", "Маркетинг"),
("video", "Видео-продакшен"),
("audio", "Подкастинг"),
("copywriting", "Копирайтинг"),
("webdesign", "Вебдизайн"),
("hire", "Найм людей"),
]),
("Разработка", [
("machine-learning", "Машинное Обучение"),
("data", "Данные и аналитика"),
("infra", "Инфраструктура"),
("ux", "UX/UI"),
("qa", "QA"),
("devops", "DevOps"),
("algorithms", "Алгоритмы и структуры данных"),
("math", "Математика"),
("imaging", "Компьютерное зрение"),
("nlp", "NLP"),
("ar-vr", "AR/VR"),
("iot", "IoT"),
]),
("Языки", [
("python", "Python"),
("java", "Java"),
("javascript", "JavaScript"),
("go", "Go"),
("php", "PHP"),
("ruby", "Ruby"),
("swift", "Swift"),
("cplus", "C/C++"),
("csharp", "C#"),
])
]

7
common/data/hats.py Normal file
View File

@ -0,0 +1,7 @@
HATS = {
"moderator": {
"title": "Модератор",
"icon": None,
"color": "#000000"
}
}

47
common/data/labels.py Normal file
View File

@ -0,0 +1,47 @@
LABELS = {
"approved": {
"title": "Офигенно!",
"emoji": "👍",
"color": "#fe4a49"
},
"recommended": {
"title": "Рекомендовано!",
"emoji": "🤙",
"color": "#fe4a49"
},
"top_week": {
"title": "Тема недели",
"emoji": "🚀",
"color": "#8E44AD"
},
"essential": {
"title": "Градообразующий пост",
"emoji": "🏛",
"color": "#2ab7ca"
},
"inside": {
"title": "Инсайды",
"emoji": "💎️",
"color": "#3498DB"
},
"facepalm": {
"title": "Духота",
"emoji": "🤦‍♂️",
"color": "#4a4e4d"
},
"wow": {
"title": "Все ебанулись",
"emoji": "🌚️",
"color": "#2b374b"
},
"comments": {
"title": "Дискуссия",
"emoji": "💭",
"color": "#F39C12"
},
"war": {
"title": "Эпичный срач",
"emoji": "💩",
"color": "#4f372d"
},
}

85
common/data/tags.py Normal file
View File

@ -0,0 +1,85 @@
HOBBIES = [
("travel", "🛣 Путешествую"),
("photo", "🌆 Фотографирую"),
("writing", "✏️ Пишу"),
("walking", "🚶‍♂️ Гуляю"),
("cycle", "🚴‍♀️ Велосипед"),
("pet-projects", "🏗 Пет-проджекты"),
("drinks", "🍸 Бухаю"),
("making", "👷‍♀️ Строю"),
("running", "🏃‍♂️ Бегаю"),
("books", "📚 Книги"),
("time-management", "⏰ Тайм-менеджмент"),
("collecting", "📦 Коллекционирование"),
("music", "🎙 Музыка"),
("cars", "🚘 Автомобили"),
("420", "🍁 420"),
("yoga", "🧘‍♀️ Йога"),
("gadgets", "⌚️ Гаджеты"),
("languages", "🈵 Языки"),
("anime", "😻 Аниме"),
("politics", "👩‍💼 Политика"),
("dancing", "💃 Танцы"),
("bikes", "🏍 Мотоцикл"),
("board-games", "🎲 Настолки"),
("boards", "🏂 Доски"),
("gym", "🏋️‍️ ‍Качалочка"),
("sport", "🎾 Другой спорт"),
("planes", "✈️ Самолеты"),
("yachts", "🚤 Яхты"),
("space", "🛰️ Космос"),
("stonks", "📉 stonks"),
("art", "🎨 Арт"),
("bdsm", "😶 BDSM"),
("audiophile", "🎧 Аудиофил"),
("cooking", "🍲 Готовлю"),
("games", "🎮 Геймер"),
]
PERSONAL = [
("optimism", "👍 Оптимист"),
("pessimism", "👎 Пессимист"),
("bureaucrat", "👨‍💼 Бюрократ"),
("experiments", "🧪 Экспериментатор"),
("work-hard", "👩‍💻 Трудоголик"),
("family", "👨‍👩‍👦 Семьянин"),
("extrovert", "👯‍♂️ Люблю людей"),
("introvert", "🧘‍♂️ Люблю уединение"),
("feminism", "👩‍🏫 Феминист*ка"),
("control", "🎛 Контрол-фрик"),
("mentor", "👨‍🏫 Ментор"),
("stoicism", "💪 Стоик"),
("business", "🚀 Свой бизнес"),
("abroad", "🚜 Пора валить"),
("smoker", "🚬 Курю"),
("beer", "🍻 Пиво"),
("wine", "🍷 Винишко"),
("non-drinker", "🚱 Не пью"),
("coffee", "☕️ Кофе"),
("tea", "🍵 Чай"),
("meditation", "👁 Медитирую"),
("tattoo", "👩‍🎤 Есть тату"),
("burnout", "🥵 Выгорал"),
("therapy", "👌 Хожу к терапевту"),
("vegan", "🍏 Веган"),
("healthy-food", "🥑 Здоровое питание"),
("cynic", "😕 Циник"),
("cats", "😽 Котики"),
("dogs", "🐶 Пёсики"),
("cities", "🌃 Большие города"),
("villages", "🏘 Уютные деревушки"),
("early-bird", "🌅 Жаворонок"),
("night-life", "🌜 Сова"),
("linux", "🐧 Linux"),
("mac", "🍎 Mac"),
("windows", "💻 Windows"),
("ios", "⌚️ iOS"),
("android", "📱 Android"),
("conservative", "📜 Консерватизм"),
("centrism", "🎯 Центризм"),
("libertarianism", "🗽 Либертарианство"),
("parties", "🎉 Тусовщик"),
]
TECH = [
]

38
common/embeds.py Normal file
View File

@ -0,0 +1,38 @@
import re
from django.template import loader
from common.markdown.club_renderer import YOUTUBE_RE
IMAGE_RE = re.compile(r"(http(s?):)([/|.|\w|\s|-])*\.(?:jpg|jpeg|gif|png|ico)")
CUSTOM_ICONS = {
"www.youtube.com": """<i class="fab fa-youtube"></i>""",
"youtube.com": """<i class="fab fa-youtube"></i>""",
"youtu.be": """<i class="fab fa-youtube"></i>""",
"github.com": """<i class="fab fa-github"></i>""",
"twitter.com": """<i class="fab fa-twitter"></i>""",
"facebook.com": """<i class="fab fa-facebook"></i>""",
"www.patreon.com": """<i class="fab fa-patreon"></i>""",
"apple.com": """<i class="fab fa-apple"></i>""",
"vk.com": """<i class="fab fa-vk"></i>""",
"medium.com": """<i class="fab fa-medium"></i>""",
}
CUSTOM_PARSERS = {
"www.youtube.com": {
"template": loader.get_template("posts/embeds/youtube.html"),
"data": lambda post: {
"src": YOUTUBE_RE.match(post.url).group(1)
}
},
"github.com": {
"template": loader.get_template("posts/embeds/github.html"),
"data": lambda post: {
"metadata": post.metadata
}
},
"www.patreon.com": {
"do_not_parse": True
},
}

View File

View File

@ -0,0 +1,73 @@
import re
import mistune
from mistune import escape_html
IMAGE_RE = re.compile(r"(http(s?):)([/|.|\w|\s|-])*\.(?:jpg|jpeg|gif|png)")
VIDEO_RE = re.compile(r"(http(s?):)([/|.|\w|\s|-])*\.(?:mov|mp4)")
YOUTUBE_RE = re.compile(
r"http(?:s?):\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?"
)
TWITTER_RE = re.compile(r"(https?:\/\/twitter.com\/[a-zA-Z0-9_]+\/status\/[\d]+)")
class ClubRenderer(mistune.HTMLRenderer):
def link(self, link, text=None, title=None):
if IMAGE_RE.match(link):
return self.image(link, text or "", title or "")
if YOUTUBE_RE.match(link):
return self.youtube(link, text, title)
if VIDEO_RE.match(link):
return self.video(link, text, title)
if TWITTER_RE.match(link):
return self.tweet(link, text, title)
return super().link(link, text, title)
def image(self, src, alt="", title=None):
if IMAGE_RE.match(src):
return self.just_img(src, alt, title)
if YOUTUBE_RE.match(src):
return self.youtube(src, alt, title)
if VIDEO_RE.match(src):
return self.video(src, alt, title)
if TWITTER_RE.match(src):
return self.tweet(src, alt, title)
# if its not an image or video, display as a link
return f'<a href="{src}">{src}</a>'
def just_img(self, src, alt="", title=None):
image_tag = f'<img src="{src}" alt="{alt}">'
caption = f"<figcaption>{escape_html(title)}</figcaption>" if title else ""
return f"<figure>{image_tag}{caption}</figure>"
def youtube(self, src, alt="", title=None):
youtube_match = YOUTUBE_RE.match(src)
video_tag = (
f'<span class="ratio-16-9">'
f'<iframe src="https://www.youtube.com/embed/{escape_html(youtube_match.group(1))}'
f'?autoplay=0&amp;controls=1&amp;showinfo=1&amp;vq=hd1080" frameborder="0"></iframe>'
f"</span>"
)
caption = f"<figcaption>{escape_html(title)}</figcaption>" if title else ""
return f"<figure>{video_tag}{caption}</figure>"
def video(self, src, alt="", title=None):
video_tag = (
f'<video src="{src}" controls autoplay loop muted playsinline>{alt}</video>'
)
caption = f"<figcaption>{escape_html(title)}</figcaption>" if title else ""
return f"<figure>{video_tag}{caption}</figure>"
def tweet(self, src, alt="", title=None):
tweet_match = TWITTER_RE.match(src)
twitter_tag = f'<blockquote class="twitter-tweet" tw-align-center>' \
f'<a href="{tweet_match.group(1)}"></a></blockquote>'
return twitter_tag

View File

@ -0,0 +1,21 @@
from mistune import escape_html
from common.markdown.club_renderer import ClubRenderer, YOUTUBE_RE
class EmailRenderer(ClubRenderer):
def just_img(self, src, alt="", title=None):
return f"""<img src="{src}" alt="{alt}" width="600" border="0"><br>{title or ""}"""
def youtube(self, src, alt="", title=None):
youtube_match = YOUTUBE_RE.match(src)
youtube_id = escape_html(youtube_match.group(1))
return f'<a href="{src}"><span class="ratio-16-9 video-preview" ' \
f'style="background-image: url(\'https://img.youtube.com/vi/{youtube_id}/0.jpg\');">' \
f'</span></a><br>{title or ""}'
def video(self, src, alt="", title=None):
return f'<video src="{src}" controls autoplay loop muted playsinline>{alt}</video><br>{title or ""}'
def tweet(self, src, alt="", title=None):
return f'<a href="{src}">{src}</a><br>{title or ""}'

View File

@ -0,0 +1,20 @@
import mistune
from common.markdown.club_renderer import ClubRenderer
from common.markdown.email_renderer import EmailRenderer
from common.markdown.plain_renderer import PlainRenderer
def markdown_text(text, renderer=ClubRenderer):
markdown = mistune.create_markdown(
escape=True, renderer=renderer(), plugins=["strikethrough", "url"]
)
return (markdown(text) or "").strip()
def markdown_plain(text):
return markdown_text(text, renderer=PlainRenderer)
def markdown_email(text):
return markdown_text(text, renderer=EmailRenderer)

View File

@ -0,0 +1,39 @@
import mistune
class PlainRenderer(mistune.HTMLRenderer):
def link(self, link, text=None, title=None):
return link
def image(self, src, alt="", title=None):
return "🖼"
def emphasis(self, text):
return text
def strong(self, text):
return text
def codespan(self, text):
return text
def linebreak(self):
return "\n"
def paragraph(self, text):
return text + "\n\n"
def heading(self, text, level):
return text + "\n\n"
def newline(self):
return "\n"
def block_quote(self, text):
return text
def list(self, text, ordered, level, start=None):
return text
def list_item(self, text, level):
return "- " + text

8
common/pagination.py Normal file
View File

@ -0,0 +1,8 @@
from django.conf import settings
from django.core.paginator import Paginator
def paginate(request, items):
paginator = Paginator(items, settings.DEFAULT_PAGE_SIZE)
page_number = request.GET.get("page") or 1
return paginator.get_page(page_number)

33
common/request.py Normal file
View File

@ -0,0 +1,33 @@
from django.http import JsonResponse, Http404
from django.shortcuts import redirect
def parse_ip_address(request):
return request.META.get("HTTP_X_REAL_IP") \
or request.META.get("HTTP_X_FORWARDED_FOR") \
or request.environ["REMOTE_ADDR"]
def parse_useragent(request):
return (request.META.get("HTTP_USER_AGENT") or "")[:512]
def is_ajax(request):
return bool(request.GET.get("is_ajax"))
def ajax_request(view):
def wrapper(request, *args, **kwargs):
status_code = 200
try:
results = view(request, *args, **kwargs)
except Http404:
status_code = 404
results = {"error": "Not Found"}
if is_ajax(request):
return JsonResponse(data=results, status=status_code)
else:
return redirect(request.META.get("HTTP_REFERER") or "/")
return wrapper

View File

@ -0,0 +1,108 @@
import io
import logging
from collections import namedtuple
from typing import Optional
from urllib.parse import urljoin, urlparse
import requests
from django.utils.html import strip_tags
from newspaper import ArticleException, Config, Article
from requests import RequestException
from urllib3.exceptions import InsecureRequestWarning
DEFAULT_REQUEST_HEADERS = {
"User-Agent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 "
"Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
}
DEFAULT_REQUEST_TIMEOUT = 10
MAX_PARSABLE_CONTENT_LENGTH = 15 * 1024 * 1024 # 15Mb
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)
log = logging.getLogger(__name__)
ParsedURL = namedtuple("ParsedURL", ["url", "domain", "title", "favicon", "summary", "image"])
def parse_url_preview(url: str) -> Optional[ParsedURL]:
real_url, content_type, content_length = resolve_url(url)
# do not parse non-text content
if not content_type or not content_type.startswith("text/"):
return None
try:
article = load_and_parse_full_article_text_and_image(real_url)
except ArticleException:
return None
canonical_url = article.canonical_link or real_url
return ParsedURL(
url=canonical_url,
domain=urlparse(canonical_url).netloc,
title=strip_tags(article.title),
favicon=strip_tags(urljoin(article.url, article.meta_favicon)),
summary=strip_tags(article.summary),
image=article.top_image,
)
def resolve_url(entry_link):
url = str(entry_link)
content_type = None
content_length = MAX_PARSABLE_CONTENT_LENGTH + 1 # don't parse null content-types
depth = 10
while depth > 0:
depth -= 1
try:
response = requests.head(url, timeout=DEFAULT_REQUEST_TIMEOUT, verify=False, stream=True)
except RequestException:
log.warning(f"Failed to resolve URL: {url}")
return None, content_type, content_length
if 300 < response.status_code < 400:
url = response.headers["location"] # follow redirect
else:
content_type = response.headers.get("content-type")
content_length = int(response.headers.get("content-length") or 0)
break
return url, content_type, content_length
def load_page_safe(url: str) -> str:
try:
response = requests.get(
url=url,
timeout=DEFAULT_REQUEST_TIMEOUT,
headers=DEFAULT_REQUEST_HEADERS,
stream=True # the most important part — stream response to prevent loading everything into memory
)
except RequestException as ex:
log.warning(f"Error parsing the page: {url} {ex}")
return ""
html = io.StringIO()
total_bytes = 0
for chunk in response.iter_content(chunk_size=100 * 1024, decode_unicode=True):
total_bytes += len(chunk)
if total_bytes >= MAX_PARSABLE_CONTENT_LENGTH:
return "" # reject too big pages
html.write(chunk)
return html.getvalue()
def load_and_parse_full_article_text_and_image(url: str) -> Article:
config = Config()
config.MAX_SUMMARY_SENT = 8
article = Article(url, config=config)
article.set_html(load_page_safe(url)) # safer than article.download()
article.parse()
article.nlp()
return article

68
docker-compose.yml Normal file
View File

@ -0,0 +1,68 @@
version: '3.7'
services:
club_app: &app
build:
context: .
command: make docker-run-dev
container_name: club_app
environment:
- DEBUG=true
- PYTHONUNBUFFERED=1
- POSTGRES_DB=vas3k_club
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_HOST=postgres
- REDIS_DB=0
- REDIS_HOST=redis
restart: always
volumes:
- .:/app:delegated # enable hot code reload in debug mode
depends_on:
- postgres
- redis
- queue
ports:
- "8000:8000"
queue:
build:
context: .
command: make run-queue
environment:
- DEBUG=true
- PYTHONUNBUFFERED=1
- POSTGRES_DB=vas3k_club
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_HOST=postgres
- REDIS_DB=0
- REDIS_HOST=redis
restart: always
volumes:
- .:/app:delegated
depends_on:
- postgres
- redis
postgres:
image: postgres:11
container_name: club_postgres
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=vas3k_club
ports:
- 5432
redis:
image: "redis:alpine"
environment:
- ALLOW_EMPTY_PASSWORD=yes
migrate_and_init:
<<: *app
container_name: club_migrate_and_init
restart: "no"
ports: []
command: make migrate

2
etc/crontab Normal file
View File

@ -0,0 +1,2 @@
0 3 * * * cd /home/vas3k/vas3k.club/vas3k_club && pipenv run python renew_subscriptions
0 4 * * * sudo certbot renew

View File

@ -0,0 +1,42 @@
upstream i_vas3k_ru_sock {
server unix:/home/vas3k/i.vas3k.club.sock weight=1 max_fails=5 fail_timeout=30s;
}
server {
listen 80;
listen 443 ssl http2;
server_name i.vas3k.club;
charset utf-8;
client_max_body_size 100M;
uwsgi_buffers 128 16k;
real_ip_header X-Real-IP;
ssl_certificate /etc/letsencrypt/live/vas3k.club/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/vas3k.club/privkey.pem; # managed by Certbot
location ~ ^/(images|videos)/ {
root /home/vas3k/i.vas3k.club;
gzip_static on;
access_log off;
expires max;
add_header Cache-Control "public";
internal;
break;
}
location / {
add_header "Access-Control-Allow-Origin" "*";
add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS";
add_header "Access-Control-Allow-Headers" "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range";
add_header "Access-Control-Expose-Headers" "Content-Length,Content-Range";
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
proxy_buffering off;
proxy_pass http://i_vas3k_ru_sock;
}
}

60
etc/nginx/vas3k.club.conf Normal file
View File

@ -0,0 +1,60 @@
upstream vas3k_club_sock {
server unix:/home/vas3k/vas3k.club.sock weight=1 max_fails=5 fail_timeout=30s;
}
server {
listen 80;
listen [::]:80;
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name www.vas3k.club;
rewrite ^(.*) https://vas3k.club$1 permanent;
}
server {
listen 80;
listen [::]:80;
server_name vas3k.club email.vas3k.club admin.vas3k.club;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2 default_server;
listen [::]:443 ssl http2 default_server;
server_name vas3k.club;
charset utf-8;
client_max_body_size 30M;
index index.html index.htm;
set_real_ip_from 172.17.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
rewrite ^/favicon.ico$ https://vas3k.club/static/images/favicon/favicon.ico;
rewrite ^/favicon.png$ https://vas3k.club/static/images/favicon/favicon-32x32.png;
ssl_certificate /etc/letsencrypt/live/vas3k.club/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/vas3k.club/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
location /static/ {
root /home/vas3k/vas3k.club/frontend/;
gzip_static on;
expires max;
add_header Cache-Control "public";
}
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
proxy_buffering off;
proxy_pass http://vas3k_club_sock;
}
}

View File

@ -0,0 +1,11 @@
[program:vas3k_club_images]
directory=/home/vas3k/i.vas3k.club
command=/home/vas3k/.local/bin/gunicorn -w 4 -u vas3k -b unix:/home/vas3k/i.vas3k.club.sock app:app
user=vas3k
numprocs=1
autostart=true
autorestart=true
process_name=vas3k-club-images-%(process_num)d
redirect_stderr=true
stdout_logfile=/var/log/supervisor/vas3k_club_images.log
stdout_logfile_maxbytes=20MB

View File

@ -0,0 +1,13 @@
[program:vas3k_club_queue]
directory=/home/vas3k/vas3k.club
command=make run-queue
user=vas3k
autostart=true
autorestart=true
stopasgroup = true
killasgroup = true
numprocs=1
process_name=vas3k-club-queue-%(process_num)d
redirect_stderr=true
stdout_logfile=/var/log/supervisor/vas3k_club_queue.log
stdout_logfile_maxbytes=50MB

View File

@ -0,0 +1,12 @@
[fcgi-program:vas3k_club_uvicorn]
directory=/home/vas3k/vas3k.club
command=make run-uvicorn
user=vas3k
socket=unix:///home/vas3k/vas3k.club.sock
autostart=true
autorestart=true
numprocs=6
process_name=vas3k-club-uvicorn-%(process_num)d
redirect_stderr=true
stdout_logfile=/var/log/supervisor/vas3k_club_uvicorn.log
stdout_logfile_maxbytes=50MB

View File

@ -0,0 +1,28 @@
{% extends "layout.html" %}
{% load static %}
{% block title %}
Режим Бога — {{ block.super }}
{% endblock %}
{% block content %}
<div class="content">
<div class="profile-header">Режим Бога</div>
<div class="block">
<form action="." method="post" enctype="multipart/form-data">
{% csrf_token %}
{% for row in form %}
<div class="form-row">
{{ row.label_tag }}
{{ row }}
{% if row.errors %}<span class="form-row-errors">{{ row.errors }}</span>{% endif %}
</div>
{% endfor %}
<button type="submit" class="button">Обновить настройки</button>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends "layout.html" %}
{% load static %}
{% block title %}
{{ title | default:"Админим" }} — {{ block.super }}
{% endblock %}
{% block content %}
<div class="content">
<div class="profile-header">{{ title | default:"Админим" }}</div>
<div class="block">
<form action="." method="post" enctype="multipart/form-data">
{% csrf_token %}
{% for row in form %}
<div class="form-row">
{{ row.label_tag }}
{{ row }}
{% if row.errors %}<span class="form-row-errors">{{ row.errors }}</span>{% endif %}
</div>
{% endfor %}
<button type="submit" class="button">Готово</button>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "layout.html" %}
{% block content %}
<div class="block access-denied">
<h2>Эта страница доступна только участникам Клуба 🎩</h2>
<p>
Клуб — это наше закрытое и в меру айтишное сообщество,
где мы обсуждаем важные темы вдали от большого интернета.
</p>
<p>
Потому что иногда просто необходимо закрыть дверь чтобы поговорить откровенно.
Вот более <a href="{% url "index" %}">подробное описание</a>.
</p>
<p>
<br>
<a href="{% url "index" %}#join" class="button">Вступить в Клуб</a>&nbsp;&nbsp;или&nbsp;&nbsp;<a href="{% url "patreon_login" %}">войти</a>
</p>
</div>
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "layout.html" %}
{% block content %}
<div class="message auth-form">
<h2>Вход по клубной карте</h2>
<p>
<br><br>
<a href="{% url "patreon_login" %}" class="button">Войти</a>
</p>
</div>
{% endblock %}

View File

@ -0,0 +1,21 @@
{% extends "layout.html" %}
{% load text_filters %}
{% block content %}
<div class="content comment">
<form action="{% url "edit_comment" comment.id %}" method="post" class="form comment-form-form">
<div class="comment-form">
<div class="comment-form-avatar">
<div class="avatar" style="background-image: url('{% if form.instance.author.avatar %}{{ form.instance.author.get_avatar }}{% else %}{{ me.get_avatar }}{% endif %}');"></div>
</div>
<div class="comment-form-body">
{{ form.text }}
{% if form.text.errors %}<span class="form-errors">{{ form.full_name.errors }}</span>{% endif %}
</div>
<div class="comment-form-button">
<button type="submit" class="button">Сохранить</button>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,14 @@
<form action="{% url "create_comment" post.slug %}" method="post" class="form comment-form-form">
<div class="comment-form">
<div class="comment-form-avatar">
<div class="avatar" style="background-image: url('{{ me.get_avatar }}');"></div>
</div>
<div class="comment-form-body">
{{ form.text }}
{% if form.text.errors %}<span class="form-errors">{{ form.full_name.errors }}</span>{% endif %}
</div>
<div class="comment-form-button">
<button type="submit" class="button">Отправить</button>
</div>
</div>
</form>

View File

@ -0,0 +1,18 @@
<form action="{% url "create_comment" post.slug %}" method="post" class="form">
<div class="reply-form" style="display: none;" id="reply-form-{{ comment.id }}">
<div class="reply-form-avatar">
<div class="avatar" style="background-image: url('{{ me.get_avatar }}');"></div>
</div>
<div class="reply-form-body">
{{ reply_form.text }}
{% if reply_form.text.errors %}<span class="form-errors">{{ reply_form.full_name.errors }}</span>{% endif %}
</div>
<input type="hidden" name="reply_to_id" value="{{ comment.id }}">
<div class="reply-form-button">
<button type="submit" class="button button-small">Отправить</button>
</div>
</div>
</form>

View File

@ -0,0 +1,21 @@
{% load comments %}
{% for subtree in comments|comment_tree:"top" %}
{% if subtree.comment.is_pinned %}
{% include "comments/types/bold.html" with comment=subtree.comment %}
{% else %}
{% include "comments/types/normal.html" with comment=subtree.comment %}
{% endif %}
{% if subtree.replies %}
<div class="comment-replies">
{% for reply in subtree.replies %}
{% include "comments/types/reply.html" with comment=reply %}
{% endfor %}
</div>
<div class="clearfix20"></div>
{% endif %}
{% if post.is_commentable and me %}
{% include "comments/forms/reply.html" with comment=subtree.comment reply_form=reply_form %}
{% endif %}
{% endfor %}

View File

@ -0,0 +1,52 @@
{% load comments %}
<div class="comment block comment-type-bold" id="comment-{{ comment.id }}">
<div class="comment-header">
<div class="user user-small">
<span class="user-avatar">
<a href="{% url "profile" comment.author.slug %}">
<span class="avatar" style="background-image: url('{{ comment.author.get_avatar }}');"></span>
</a>
</span>
<span class="user-info">
<a href="{% url "profile" comment.author.slug %}">
<span class="user-name">{{ comment.author.full_name }}</span>{% if me %}<span class="user-position">, {{ comment.author.position }}</span>{% endif %}
{% if comment.author.hat %}{% include "users/widgets/hat.html" with hat=comment.author.hat %}{% endif %}
</a>
</span>
<span class="user-footer">
<a href="#comment-{{ comment.id }}">{{ comment.created_at | date:"d E Y" }}</a>
</span>
</div>
</div>
<div class="comment-rating">
{% if me %}
<a href="{% url "upvote_comment" comment.id %}" class="upvote {% if comment.is_voted %}upvote-voted{% endif %} post-body-rating" onclick="ajaxify(event, commentUpvoted)">{{ comment.upvotes }}</a>
{% else %}
<span class="upvote upvote-voted post-body-rating">{{ comment.upvotes }}</span>
{% endif %}
{% if comment.is_pinned %}
<div class="comment-pinned-icon"><i class="fas fa-thumbtack"></i></div>
{% endif %}
</div>
<div class="comment-body">
<div class="text-body text-body-type-comment">
{% render_comment comment %}
</div>
</div>
<div class="comment-footer">
{% if me == comment.post.author or me.is_moderator %}
<a href="{% url "pin_comment" comment.id %}" class="comment-edit-button comment-button-hidden"><i class="fas fa-thumbtack"></i></a>
{% endif %}
{% if me == comment.author or me == comment.post.author or me.is_moderator %}
<a href="{% url "delete_comment" comment.id %}" class="comment-edit-button comment-button-hidden"><i class="fas fa-trash"></i></a>
{% endif %}
{% if me == comment.author or me.is_moderator %}
<a href="{% url "edit_comment" comment.id %}" class="comment-edit-button comment-button-hidden"><i class="fas fa-edit"></i></a>
{% endif %}
<span class="comment-reply-button" onclick="showReplyForm('{{ comment.id }}', '', true)"><i class="fas fa-reply"></i>&nbsp;ответить</span>
</div>
</div>

View File

@ -0,0 +1,52 @@
{% load comments %}
<div class="comment comment-type-normal {% if comment.is_pinned %}comment-pinned{% endif %}" id="comment-{{ comment.id }}">
<div class="comment-header">
<div class="user user-small">
<span class="user-avatar">
<a href="{% url "profile" comment.author.slug %}">
<span class="avatar" style="background-image: url('{{ comment.author.get_avatar }}');"></span>
</a>
</span>
<span class="user-info">
<a href="{% url "profile" comment.author.slug %}">
<span class="user-name">{{ comment.author.full_name }}</span>{% if me %}<span class="user-position">, {{ comment.author.position }}</span>{% endif %}
{% if comment.author.hat %}{% include "users/widgets/hat.html" with hat=comment.author.hat %}{% endif %}
</a>
</span>
<span class="user-footer">
<a href="#comment-{{ comment.id }}">{{ comment.created_at | date:"d E Y" }}</a>
</span>
</div>
</div>
<div class="comment-rating">
{% if me %}
<a href="{% url "upvote_comment" comment.id %}" class="upvote {% if comment.is_voted %}upvote-voted{% endif %} upvote-type-small" onclick="ajaxify(event, commentUpvoted)">{{ comment.upvotes }}</a>
{% else %}
<span class="upvote upvote-voted upvote-type-small">{{ comment.upvotes }}</span>
{% endif %}
{% if comment.is_pinned %}
<div class="comment-pinned-icon"><i class="fas fa-thumbtack"></i></div>
{% endif %}
</div>
<div class="comment-body">
<div class="text-body text-body-type-comment">
{% render_comment comment %}
</div>
</div>
<div class="comment-footer">
{% if me == comment.post.author or me.is_moderator %}
<a href="{% url "pin_comment" comment.id %}" class="comment-edit-button comment-button-hidden"><i class="fas fa-thumbtack"></i></a>
{% endif %}
{% if me == comment.author or me == comment.post.author or me.is_moderator %}
<a href="{% url "delete_comment" comment.id %}" class="comment-edit-button comment-button-hidden"><i class="fas fa-trash"></i></a>
{% endif %}
{% if me == comment.author or me.is_moderator %}
<a href="{% url "edit_comment" comment.id %}" class="comment-edit-button comment-button-hidden"><i class="fas fa-edit"></i></a>
{% endif %}
<span class="comment-reply-button" onclick="showReplyForm('{{ comment.id }}', '', true)"><i class="fas fa-reply"></i>&nbsp;ответить</span>
</div>
</div>

View File

@ -0,0 +1,31 @@
{% load comments %}
<div class="reply" id="comment-{{ comment.id }}">
<div class="reply-author">
<a href="{% url "profile" comment.author.slug %}">
<span class="avatar user-avatar" style="background-image: url('{{ comment.author.get_avatar }}');"></span>
</a>
</div>
<div class="reply-body">
<div class="text-body text-body-type-comment">
{% render_comment comment %}
</div>
</div>
<div class="reply-rating">
{% if me %}
<a href="{% url "upvote_comment" comment.id %}" class="upvote {% if comment.is_voted %}upvote-voted{% endif %} upvote-type-inline" onclick="ajaxify(event, commentUpvoted)">{{ comment.upvotes }}</a>
{% else %}
<span class="upvote upvote-voted upvote-type-inline">{{ comment.upvotes }}</span>
{% endif %}
</div>
<div class="reply-footer">
<span class="comment-reply-button" onclick="showReplyForm('{{ comment.reply_to_id }}', '{{ comment.author.slug }}', true)">
&nbsp;&nbsp;{{ comment.author.slug }}&nbsp;&nbsp;<i class="fas fa-reply"></i>
</span>
{% if comment.author == me and comment.is_editable or me.is_moderator %}
<a href="{% url "edit_comment" comment.id %}" class="comment-edit-button reply-button-hidden"><i class="fas fa-edit"></i></a>
{% if not comment.is_deleted %}
<a href="{% url "delete_comment" comment.id %}" class="comment-edit-button reply-button-hidden"><i class="fas fa-trash"></i></a>
{% endif %}
{% endif %}
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More