Squash history before public relese
This commit is contained in:
commit
c3f7a94bc5
|
@ -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/
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthConfig(AppConfig):
|
||||
name = "auth"
|
|
@ -0,0 +1,2 @@
|
|||
def me(request):
|
||||
return {"me": request.me, "my_session": request.my_session}
|
|
@ -0,0 +1,9 @@
|
|||
from club.exceptions import ClubException
|
||||
|
||||
|
||||
class AuthException(ClubException):
|
||||
pass
|
||||
|
||||
|
||||
class PatreonException(AuthException):
|
||||
pass
|
|
@ -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
|
|
@ -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 🥙")
|
|
@ -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',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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"
|
|
@ -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
|
|
@ -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,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")
|
|
@ -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}")
|
|
@ -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,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BotConfig(AppConfig):
|
||||
name = 'bot'
|
|
@ -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)
|
|
@ -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,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,
|
||||
}
|
|
@ -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))
|
|
@ -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}) 👍"
|
||||
)
|
|
@ -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 🥙")
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -0,0 +1,3 @@
|
|||
👍 Ваш пост «<a href="{{ settings.APP_HOST }}{% url "show_post" post.type post.slug %}">{{ post.title }}</a>» понравился модераторам Клуба.
|
||||
|
||||
Теперь он попадёт в клубные подборки и может даже на канал. Продолжайте быть клёвым!
|
|
@ -0,0 +1,8 @@
|
|||
👎 К сожалению, модераторы перенесли ваш пост «<a href="{{ settings.APP_HOST }}{% url "show_post" post.type post.slug %}">{{ post.title }}</a>» обратно в черновики.
|
||||
|
||||
Он не прошел наш контроль качества и требует доработки. Вот популярные причины почему так бывает:
|
||||
- Пост недооформлен или, наоборот, использует слишком много разных стилей
|
||||
- Информация слишком поверхностна, а мы ждём настоящих честных инсайдов
|
||||
- Контент не несёт пользы Клубу или выглядит рекламным
|
||||
|
||||
Поработайте над ним еще. Вы можете лучше!
|
|
@ -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,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()
|
|
@ -0,0 +1,5 @@
|
|||
from django.conf import settings
|
||||
|
||||
|
||||
def settings_processor(request):
|
||||
return {"settings": settings}
|
|
@ -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 = ""
|
|
@ -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,
|
||||
)
|
|
@ -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
|
|
@ -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
|
|
@ -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,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommentsConfig(AppConfig):
|
||||
name = "comments"
|
|
@ -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"]
|
|
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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
|
|
@ -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 "")
|
|
@ -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,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;",
|
||||
},
|
||||
}
|
|
@ -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",
|
||||
]
|
|
@ -0,0 +1,265 @@
|
|||
COUNTRIES = [
|
||||
("Россия", "Россия"),
|
||||
("Украина", "Украина"),
|
||||
("Беларусь", "Беларусь"),
|
||||
("Казахстан", "Казахстан"),
|
||||
("Абхазия", "Абхазия"),
|
||||
("Австралия", "Австралия"),
|
||||
("Австрия", "Австрия"),
|
||||
("Азербайджан", "Азербайджан"),
|
||||
("Албания", "Албания"),
|
||||
("Алжир", "Алжир"),
|
||||
("Американское Самоа", "Американское Самоа"),
|
||||
("Ангилья", "Ангилья"),
|
||||
("Ангола", "Ангола"),
|
||||
("Андорра", "Андорра"),
|
||||
("Антарктида", "Антарктида"),
|
||||
("Антигуа и Барбуда", "Антигуа и Барбуда"),
|
||||
("Аргентина", "Аргентина"),
|
||||
("Армения", "Армения"),
|
||||
("Аруба", "Аруба"),
|
||||
("Афганистан", "Афганистан"),
|
||||
("Багамы", "Багамы"),
|
||||
("Бангладеш", "Бангладеш"),
|
||||
("Барбадос", "Барбадос"),
|
||||
("Бахрейн", "Бахрейн"),
|
||||
("Белиз", "Белиз"),
|
||||
("Бельгия", "Бельгия"),
|
||||
("Бенин", "Бенин"),
|
||||
("Бермуды", "Бермуды"),
|
||||
("Болгария", "Болгария"),
|
||||
("Боливия, Многонациональное Государство", "Боливия, Многонациональное Государство"),
|
||||
("Бонайре, Саба и Синт-Эстатиус", "Бонайре, Саба и Синт-Эстатиус"),
|
||||
("Босния и Герцеговина", "Босния и Герцеговина"),
|
||||
("Ботсвана", "Ботсвана"),
|
||||
("Бразилия", "Бразилия"),
|
||||
("Британская территория в Индийском океане", "Британская территория в Индийском океане"),
|
||||
("Бруней-Даруссалам", "Бруней-Даруссалам"),
|
||||
("Буркина-Фасо", "Буркина-Фасо"),
|
||||
("Бурунди", "Бурунди"),
|
||||
("Бутан", "Бутан"),
|
||||
("Вануату", "Вануату"),
|
||||
("Венгрия", "Венгрия"),
|
||||
("Венесуэла Боливарианская Республика", "Венесуэла Боливарианская Республика"),
|
||||
("Виргинские острова, Британские", "Виргинские острова, Британские"),
|
||||
("Виргинские острова, США", "Виргинские острова, США"),
|
||||
("Вьетнам", "Вьетнам"),
|
||||
("Габон", "Габон"),
|
||||
("Гаити", "Гаити"),
|
||||
("Гайана", "Гайана"),
|
||||
("Гамбия", "Гамбия"),
|
||||
("Гана", "Гана"),
|
||||
("Гваделупа", "Гваделупа"),
|
||||
("Гватемала", "Гватемала"),
|
||||
("Гвинея", "Гвинея"),
|
||||
("Гвинея-Бисау", "Гвинея-Бисау"),
|
||||
("Германия", "Германия"),
|
||||
("Гернси", "Гернси"),
|
||||
("Гибралтар", "Гибралтар"),
|
||||
("Гондурас", "Гондурас"),
|
||||
("Гонконг", "Гонконг"),
|
||||
("Гренада", "Гренада"),
|
||||
("Гренландия", "Гренландия"),
|
||||
("Греция", "Греция"),
|
||||
("Грузия", "Грузия"),
|
||||
("Гуам", "Гуам"),
|
||||
("Дания", "Дания"),
|
||||
("Джерси", "Джерси"),
|
||||
("Джибути", "Джибути"),
|
||||
("Доминика", "Доминика"),
|
||||
("Доминиканская Республика", "Доминиканская Республика"),
|
||||
("Египет", "Египет"),
|
||||
("Замбия", "Замбия"),
|
||||
("Западная Сахара", "Западная Сахара"),
|
||||
("Зимбабве", "Зимбабве"),
|
||||
("Израиль", "Израиль"),
|
||||
("Индия", "Индия"),
|
||||
("Индонезия", "Индонезия"),
|
||||
("Иордания", "Иордания"),
|
||||
("Ирак", "Ирак"),
|
||||
("Иран, Исламская Республика", "Иран, Исламская Республика"),
|
||||
("Ирландия", "Ирландия"),
|
||||
("Исландия", "Исландия"),
|
||||
("Испания", "Испания"),
|
||||
("Италия", "Италия"),
|
||||
("Йемен", "Йемен"),
|
||||
("Кабо-Верде", "Кабо-Верде"),
|
||||
("Камбоджа", "Камбоджа"),
|
||||
("Камерун", "Камерун"),
|
||||
("Канада", "Канада"),
|
||||
("Катар", "Катар"),
|
||||
("Кения", "Кения"),
|
||||
("Кипр", "Кипр"),
|
||||
("Киргизия", "Киргизия"),
|
||||
("Кирибати", "Кирибати"),
|
||||
("Китай", "Китай"),
|
||||
("Кокосовые (Килинг) острова", "Кокосовые (Килинг) острова"),
|
||||
("Колумбия", "Колумбия"),
|
||||
("Коморы", "Коморы"),
|
||||
("Конго", "Конго"),
|
||||
("Конго, Демократическая Республика", "Конго, Демократическая Республика"),
|
||||
("Корея, Народно-Демократическая Республика", "Корея, Народно-Демократическая Республика"),
|
||||
("Корея, Республика", "Корея, Республика"),
|
||||
("Коста-Рика", "Коста-Рика"),
|
||||
("Кот д'Ивуар", "Кот д'Ивуар"),
|
||||
("Куба", "Куба"),
|
||||
("Кувейт", "Кувейт"),
|
||||
("Кюрасао", "Кюрасао"),
|
||||
("Лаос", "Лаос"),
|
||||
("Латвия", "Латвия"),
|
||||
("Лесото", "Лесото"),
|
||||
("Ливан", "Ливан"),
|
||||
("Ливийская Арабская Джамахирия", "Ливийская Арабская Джамахирия"),
|
||||
("Либерия", "Либерия"),
|
||||
("Лихтенштейн", "Лихтенштейн"),
|
||||
("Литва", "Литва"),
|
||||
("Люксембург", "Люксембург"),
|
||||
("Маврикий", "Маврикий"),
|
||||
("Мавритания", "Мавритания"),
|
||||
("Мадагаскар", "Мадагаскар"),
|
||||
("Майотта", "Майотта"),
|
||||
("Макао", "Макао"),
|
||||
("Малави", "Малави"),
|
||||
("Малайзия", "Малайзия"),
|
||||
("Мали", "Мали"),
|
||||
(
|
||||
"Малые Тихоокеанские отдаленные острова Соединенных Штатов",
|
||||
"Малые Тихоокеанские отдаленные острова Соединенных Штатов",
|
||||
),
|
||||
("Мальдивы", "Мальдивы"),
|
||||
("Мальта", "Мальта"),
|
||||
("Марокко", "Марокко"),
|
||||
("Мартиника", "Мартиника"),
|
||||
("Маршалловы острова", "Маршалловы острова"),
|
||||
("Мексика", "Мексика"),
|
||||
("Микронезия, Федеративные Штаты", "Микронезия, Федеративные Штаты"),
|
||||
("Мозамбик", "Мозамбик"),
|
||||
("Молдова, Республика", "Молдова, Республика"),
|
||||
("Монако", "Монако"),
|
||||
("Монголия", "Монголия"),
|
||||
("Монтсеррат", "Монтсеррат"),
|
||||
("Мьянма", "Мьянма"),
|
||||
("Намибия", "Намибия"),
|
||||
("Науру", "Науру"),
|
||||
("Непал", "Непал"),
|
||||
("Нигер", "Нигер"),
|
||||
("Нигерия", "Нигерия"),
|
||||
("Нидерланды", "Нидерланды"),
|
||||
("Никарагуа", "Никарагуа"),
|
||||
("Ниуэ", "Ниуэ"),
|
||||
("Новая Зеландия", "Новая Зеландия"),
|
||||
("Новая Каледония", "Новая Каледония"),
|
||||
("Норвегия", "Норвегия"),
|
||||
("Объединенные Арабские Эмираты", "Объединенные Арабские Эмираты"),
|
||||
("Оман", "Оман"),
|
||||
("Остров Буве", "Остров Буве"),
|
||||
("Остров Мэн", "Остров Мэн"),
|
||||
("Остров Норфолк", "Остров Норфолк"),
|
||||
("Остров Рождества", "Остров Рождества"),
|
||||
("Остров Херд и острова Макдональд", "Остров Херд и острова Макдональд"),
|
||||
("Острова Кайман", "Острова Кайман"),
|
||||
("Острова Кука", "Острова Кука"),
|
||||
("Острова Теркс и Кайкос", "Острова Теркс и Кайкос"),
|
||||
("Пакистан", "Пакистан"),
|
||||
("Палау", "Палау"),
|
||||
(
|
||||
"Палестинская территория, оккупированная",
|
||||
"Палестинская территория, оккупированная",
|
||||
),
|
||||
("Панама", "Панама"),
|
||||
(
|
||||
"Папский Престол (Государство — город Ватикан)",
|
||||
"Папский Престол (Государство — город Ватикан)",
|
||||
),
|
||||
("Папуа-Новая Гвинея", "Папуа-Новая Гвинея"),
|
||||
("Парагвай", "Парагвай"),
|
||||
("Перу", "Перу"),
|
||||
("Питкерн", "Питкерн"),
|
||||
("Польша", "Польша"),
|
||||
("Португалия", "Португалия"),
|
||||
("Пуэрто-Рико", "Пуэрто-Рико"),
|
||||
("Республика Македония", "Республика Македония"),
|
||||
("Реюньон", "Реюньон"),
|
||||
("Руанда", "Руанда"),
|
||||
("Румыния", "Румыния"),
|
||||
("Самоа", "Самоа"),
|
||||
("Сан-Марино", "Сан-Марино"),
|
||||
("Сан-Томе и Принсипи", "Сан-Томе и Принсипи"),
|
||||
("Саудовская Аравия", "Саудовская Аравия"),
|
||||
("Свазиленд", "Свазиленд"),
|
||||
(
|
||||
"Святая Елена, Остров вознесения, Тристан-да-Кунья",
|
||||
"Святая Елена, Остров вознесения, Тристан-да-Кунья",
|
||||
),
|
||||
("Северные Марианские острова", "Северные Марианские острова"),
|
||||
("Сен-Бартельми", "Сен-Бартельми"),
|
||||
("Сен-Мартен", "Сен-Мартен"),
|
||||
("Сенегал", "Сенегал"),
|
||||
("Сент-Винсент и Гренадины", "Сент-Винсент и Гренадины"),
|
||||
("Сент-Китс и Невис", "Сент-Китс и Невис"),
|
||||
("Сент-Люсия", "Сент-Люсия"),
|
||||
("Сент-Пьер и Микелон", "Сент-Пьер и Микелон"),
|
||||
("Сербия", "Сербия"),
|
||||
("Сейшелы", "Сейшелы"),
|
||||
("Сингапур", "Сингапур"),
|
||||
("Синт-Мартен", "Синт-Мартен"),
|
||||
("Сирийская Арабская Республика", "Сирийская Арабская Республика"),
|
||||
("Словакия", "Словакия"),
|
||||
("Словения", "Словения"),
|
||||
("Соединенное Королевство", "Соединенное Королевство"),
|
||||
("Соединенные Штаты", "Соединенные Штаты"),
|
||||
("Соломоновы острова", "Соломоновы острова"),
|
||||
("Сомали", "Сомали"),
|
||||
("Судан", "Судан"),
|
||||
("Суринам", "Суринам"),
|
||||
("Сьерра-Леоне", "Сьерра-Леоне"),
|
||||
("Таджикистан", "Таджикистан"),
|
||||
("Таиланд", "Таиланд"),
|
||||
("Тайвань (Китай)", "Тайвань (Китай)"),
|
||||
("Танзания, Объединенная Республика", "Танзания, Объединенная Республика"),
|
||||
("Тимор-Лесте", "Тимор-Лесте"),
|
||||
("Того", "Того"),
|
||||
("Токелау", "Токелау"),
|
||||
("Тонга", "Тонга"),
|
||||
("Тринидад и Тобаго", "Тринидад и Тобаго"),
|
||||
("Тувалу", "Тувалу"),
|
||||
("Тунис", "Тунис"),
|
||||
("Туркмения", "Туркмения"),
|
||||
("Турция", "Турция"),
|
||||
("Уганда", "Уганда"),
|
||||
("Узбекистан", "Узбекистан"),
|
||||
("Уоллис и Футуна", "Уоллис и Футуна"),
|
||||
("Уругвай", "Уругвай"),
|
||||
("Фарерские острова", "Фарерские острова"),
|
||||
("Фиджи", "Фиджи"),
|
||||
("Филиппины", "Филиппины"),
|
||||
("Финляндия", "Финляндия"),
|
||||
("Фолклендские острова (Мальвинские)", "Фолклендские острова (Мальвинские)"),
|
||||
("Франция", "Франция"),
|
||||
("Французская Гвиана", "Французская Гвиана"),
|
||||
("Французская Полинезия", "Французская Полинезия"),
|
||||
("Французские Южные территории", "Французские Южные территории"),
|
||||
("Хорватия", "Хорватия"),
|
||||
("Центрально-Африканская Республика", "Центрально-Африканская Республика"),
|
||||
("Чад", "Чад"),
|
||||
("Черногория", "Черногория"),
|
||||
("Чешская Республика", "Чешская Республика"),
|
||||
("Чили", "Чили"),
|
||||
("Швейцария", "Швейцария"),
|
||||
("Швеция", "Швеция"),
|
||||
("Шпицберген и Ян Майен", "Шпицберген и Ян Майен"),
|
||||
("Шри-Ланка", "Шри-Ланка"),
|
||||
("Эквадор", "Эквадор"),
|
||||
("Экваториальная Гвинея", "Экваториальная Гвинея"),
|
||||
("Эландские острова", "Эландские острова"),
|
||||
("Эль-Сальвадор", "Эль-Сальвадор"),
|
||||
("Эритрея", "Эритрея"),
|
||||
("Эстония", "Эстония"),
|
||||
("Эфиопия", "Эфиопия"),
|
||||
("Южная Африка", "Южная Африка"),
|
||||
("Южная Джорджия и Южные Сандвичевы острова", "Южная Джорджия и Южные Сандвичевы острова"),
|
||||
("Южная Осетия", "Южная Осетия"),
|
||||
("Южный Судан", "Южный Судан"),
|
||||
("Ямайка", "Ямайка"),
|
||||
("Япония", "Япония"),
|
||||
]
|
|
@ -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#"),
|
||||
])
|
||||
]
|
|
@ -0,0 +1,7 @@
|
|||
HATS = {
|
||||
"moderator": {
|
||||
"title": "Модератор",
|
||||
"icon": None,
|
||||
"color": "#000000"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
},
|
||||
}
|
|
@ -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 = [
|
||||
]
|
|
@ -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
|
||||
},
|
||||
}
|
|
@ -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&controls=1&showinfo=1&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
|
|
@ -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 ""}'
|
|
@ -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)
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
0 3 * * * cd /home/vas3k/vas3k.club/vas3k_club && pipenv run python renew_subscriptions
|
||||
0 4 * * * sudo certbot renew
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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> или <a href="{% url "patreon_login" %}">войти</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -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> ответить</span>
|
||||
</div>
|
||||
</div>
|
|
@ -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> ответить</span>
|
||||
</div>
|
||||
</div>
|
|
@ -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)">
|
||||
— {{ comment.author.slug }} <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
Loading…
Reference in New Issue