mirror of
https://github.com/Alexander-D-Karpov/akarpov
synced 2025-04-19 10:31:59 +03:00
inited django
This commit is contained in:
commit
3f806b13a2
10
.dockerignore
Normal file
10
.dockerignore
Normal file
|
@ -0,0 +1,10 @@
|
|||
.editorconfig
|
||||
.gitattributes
|
||||
.github
|
||||
.gitignore
|
||||
.gitlab-ci.yml
|
||||
.idea
|
||||
.pre-commit-config.yaml
|
||||
.readthedocs.yml
|
||||
.travis.yml
|
||||
venv
|
27
.editorconfig
Normal file
27
.editorconfig
Normal file
|
@ -0,0 +1,27 @@
|
|||
# http://editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{py,rst,ini}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{html,css,scss,json,yml,xml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[nginx.conf]
|
||||
indent_style = space
|
||||
indent_size = 2
|
3
.env.example
Normal file
3
.env.example
Normal file
|
@ -0,0 +1,3 @@
|
|||
DATABASE_URL=
|
||||
CELERY_BROKER_URL=
|
||||
USE_DOCKER=
|
14
.envs/.local/.django
Normal file
14
.envs/.local/.django
Normal file
|
@ -0,0 +1,14 @@
|
|||
# General
|
||||
# ------------------------------------------------------------------------------
|
||||
USE_DOCKER=yes
|
||||
IPYTHONDIR=/app/.ipython
|
||||
# Redis
|
||||
# ------------------------------------------------------------------------------
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Celery
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# Flower
|
||||
CELERY_FLOWER_USER=debug
|
||||
CELERY_FLOWER_PASSWORD=debug
|
7
.envs/.local/.postgres
Normal file
7
.envs/.local/.postgres
Normal file
|
@ -0,0 +1,7 @@
|
|||
# PostgreSQL
|
||||
# ------------------------------------------------------------------------------
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=akarpov
|
||||
POSTGRES_USER=debug
|
||||
POSTGRES_PASSWORD=debug
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
* text=auto
|
79
.github/dependabot.yml
vendored
Normal file
79
.github/dependabot.yml
vendored
Normal file
|
@ -0,0 +1,79 @@
|
|||
# Config for Dependabot updates. See Documentation here:
|
||||
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Update GitHub actions in workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
# Check for updates to GitHub Actions every weekday
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
# Enable version updates for Docker
|
||||
# We need to specify each Dockerfile in a separate entry because Dependabot doesn't
|
||||
# support wildcards or recursively checking subdirectories. Check this issue for updates:
|
||||
# https://github.com/dependabot/dependabot-core/issues/2178
|
||||
- package-ecosystem: "docker"
|
||||
# Look for a `Dockerfile` in the `compose/local/django` directory
|
||||
directory: "compose/local/django/"
|
||||
# Check for updates to GitHub Actions every weekday
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
# Enable version updates for Docker
|
||||
- package-ecosystem: "docker"
|
||||
# Look for a `Dockerfile` in the `compose/local/docs` directory
|
||||
directory: "compose/local/docs/"
|
||||
# Check for updates to GitHub Actions every weekday
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
# Enable version updates for Docker
|
||||
- package-ecosystem: "docker"
|
||||
# Look for a `Dockerfile` in the `compose/local/node` directory
|
||||
directory: "compose/local/node/"
|
||||
# Check for updates to GitHub Actions every weekday
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
# Enable version updates for Docker
|
||||
- package-ecosystem: "docker"
|
||||
# Look for a `Dockerfile` in the `compose/production/aws` directory
|
||||
directory: "compose/production/aws/"
|
||||
# Check for updates to GitHub Actions every weekday
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
# Enable version updates for Docker
|
||||
- package-ecosystem: "docker"
|
||||
# Look for a `Dockerfile` in the `compose/production/django` directory
|
||||
directory: "compose/production/django/"
|
||||
# Check for updates to GitHub Actions every weekday
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
# Enable version updates for Docker
|
||||
- package-ecosystem: "docker"
|
||||
# Look for a `Dockerfile` in the `compose/production/postgres` directory
|
||||
directory: "compose/production/postgres/"
|
||||
# Check for updates to GitHub Actions every weekday
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
# Enable version updates for Docker
|
||||
- package-ecosystem: "docker"
|
||||
# Look for a `Dockerfile` in the `compose/production/traefik` directory
|
||||
directory: "compose/production/traefik/"
|
||||
# Check for updates to GitHub Actions every weekday
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
# Enable version updates for Python/Pip - Production
|
||||
- package-ecosystem: "pip"
|
||||
# Look for a `requirements.txt` in the `root` directory
|
||||
# also 'setup.cfg', 'runtime.txt' and 'requirements/*.txt'
|
||||
directory: "/"
|
||||
# Check for updates to GitHub Actions every weekday
|
||||
schedule:
|
||||
interval: "daily"
|
60
.github/workflows/ci.yml
vendored
Normal file
60
.github/workflows/ci.yml
vendored
Normal file
|
@ -0,0 +1,60 @@
|
|||
name: CI
|
||||
|
||||
# Enable Buildkit and let compose use it to speed up image building
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ "master", "main" ]
|
||||
paths-ignore: [ "docs/**" ]
|
||||
|
||||
push:
|
||||
branches: [ "master", "main" ]
|
||||
paths-ignore: [ "docs/**" ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
linter:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Checkout Code Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
requirements/base.txt
|
||||
requirements/local.txt
|
||||
|
||||
- name: Run pre-commit
|
||||
uses: pre-commit/action@v2.0.3
|
||||
|
||||
# With no caching at all the entire ci process takes 4m 30s to complete!
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
- name: Checkout Code Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build the Stack
|
||||
run: docker-compose -f local.yml build
|
||||
|
||||
- name: Run DB Migrations
|
||||
run: docker-compose -f local.yml run --rm django python manage.py migrate
|
||||
|
||||
- name: Run Django Tests
|
||||
run: docker-compose -f local.yml run django pytest
|
||||
|
||||
- name: Tear down the Stack
|
||||
run: docker-compose -f local.yml down
|
335
.gitignore
vendored
Normal file
335
.gitignore
vendored
Normal file
|
@ -0,0 +1,335 @@
|
|||
.env
|
||||
.idea
|
||||
### Python template
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
staticfiles/
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# Environments
|
||||
.venv
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
|
||||
|
||||
### Node template
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Typescript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
|
||||
### Linux template
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
|
||||
### VisualStudioCode template
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
|
||||
# Provided default Pycharm Run/Debug Configurations should be tracked by git
|
||||
# In case of local modifications made by Pycharm, use update-index command
|
||||
# for each changed file, like this:
|
||||
# git update-index --assume-unchanged .idea/akarpov.iml
|
||||
### JetBrains template
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff:
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/dictionaries
|
||||
|
||||
# Sensitive or high-churn files:
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.xml
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
|
||||
# Gradle:
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# CMake
|
||||
cmake-build-debug/
|
||||
|
||||
# Mongo Explorer plugin:
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
## File-based project format:
|
||||
*.iws
|
||||
|
||||
## Plugin-specific files:
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
|
||||
|
||||
### Windows template
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
Desktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
|
||||
### macOS template
|
||||
# General
|
||||
*.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
|
||||
### SublimeText template
|
||||
# Cache files for Sublime Text
|
||||
*.tmlanguage.cache
|
||||
*.tmPreferences.cache
|
||||
*.stTheme.cache
|
||||
|
||||
# Workspace files are user-specific
|
||||
*.sublime-workspace
|
||||
|
||||
# Project files should be checked into the repository, unless a significant
|
||||
# proportion of contributors will probably not be using Sublime Text
|
||||
# *.sublime-project
|
||||
|
||||
# SFTP configuration file
|
||||
sftp-config.json
|
||||
|
||||
# Package control specific files
|
||||
Package Control.last-run
|
||||
Package Control.ca-list
|
||||
Package Control.ca-bundle
|
||||
Package Control.system-ca-bundle
|
||||
Package Control.cache/
|
||||
Package Control.ca-certs/
|
||||
Package Control.merged-ca-bundle
|
||||
Package Control.user-ca-bundle
|
||||
oscrypto-ca-bundle.crt
|
||||
bh_unicode_properties.cache
|
||||
|
||||
# Sublime-github package stores a github token in this file
|
||||
# https://packagecontrol.io/packages/sublime-github
|
||||
GitHub.sublime-settings
|
||||
|
||||
|
||||
### Vim template
|
||||
# Swap
|
||||
[._]*.s[a-v][a-z]
|
||||
[._]*.sw[a-p]
|
||||
[._]s[a-v][a-z]
|
||||
[._]sw[a-p]
|
||||
|
||||
# Session
|
||||
Session.vim
|
||||
|
||||
# Temporary
|
||||
.netrwhist
|
||||
|
||||
# Auto-generated tag files
|
||||
tags
|
||||
|
||||
### Project template
|
||||
akarpov/media/
|
||||
|
||||
.pytest_cache/
|
||||
.ipython/
|
||||
.env
|
||||
.envs/*
|
||||
!.envs/.local/
|
39
.pre-commit-config.yaml
Normal file
39
.pre-commit-config.yaml
Normal file
|
@ -0,0 +1,39 @@
|
|||
exclude: "^docs/|/migrations/"
|
||||
default_stages: [commit]
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.2.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py310-plus]
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.10.0
|
||||
hooks:
|
||||
- id: black
|
||||
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.10.1
|
||||
hooks:
|
||||
- id: isort
|
||||
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 5.0.4
|
||||
hooks:
|
||||
- id: flake8
|
||||
args: ["--config=setup.cfg"]
|
||||
additional_dependencies: [flake8-isort]
|
||||
|
||||
# sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date
|
||||
ci:
|
||||
autoupdate_schedule: weekly
|
||||
skip: []
|
||||
submodules: false
|
14
.pylintrc
Normal file
14
.pylintrc
Normal file
|
@ -0,0 +1,14 @@
|
|||
[MASTER]
|
||||
load-plugins=pylint_django, pylint_celery
|
||||
django-settings-module=config.settings.local
|
||||
[FORMAT]
|
||||
max-line-length=120
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
disable=missing-docstring,invalid-name
|
||||
|
||||
[DESIGN]
|
||||
max-parents=13
|
||||
|
||||
[TYPECHECK]
|
||||
generated-members=REQUEST,acl_users,aq_parent,"[a-zA-Z]+_set{1,2}",save,delete
|
12
.readthedocs.yml
Normal file
12
.readthedocs.yml
Normal file
|
@ -0,0 +1,12 @@
|
|||
version: 2
|
||||
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
|
||||
build:
|
||||
image: testing
|
||||
|
||||
python:
|
||||
version: 3.10
|
||||
install:
|
||||
- requirements: requirements/local.txt
|
1
CONTRIBUTORS.txt
Normal file
1
CONTRIBUTORS.txt
Normal file
|
@ -0,0 +1 @@
|
|||
sanspie
|
9
LICENSE
Normal file
9
LICENSE
Normal file
|
@ -0,0 +1,9 @@
|
|||
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2022, sanspie
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
5
akarpov/__init__.py
Normal file
5
akarpov/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
__version__ = "0.1.0"
|
||||
__version_info__ = tuple(
|
||||
int(num) if num.isdigit() else num
|
||||
for num in __version__.replace("-", ".", 1).split(".")
|
||||
)
|
0
akarpov/blog/__init__.py
Normal file
0
akarpov/blog/__init__.py
Normal file
0
akarpov/blog/api/__init__.py
Normal file
0
akarpov/blog/api/__init__.py
Normal file
120
akarpov/blog/api/serializers.py
Normal file
120
akarpov/blog/api/serializers.py
Normal file
|
@ -0,0 +1,120 @@
|
|||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import serializers
|
||||
|
||||
from akarpov.blog.models import Comment, CommentRating, Post, PostRating
|
||||
from akarpov.blog.services.post import update_comment_rate, update_post_rating
|
||||
from akarpov.users.api.serializers import UserPublicInfoSerializer
|
||||
|
||||
|
||||
class ListPostSerializer(serializers.ModelSerializer):
|
||||
creator = UserPublicInfoSerializer()
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
view_name="retrieve_update_delete_post_api", lookup_field="slug"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Post
|
||||
fields = (
|
||||
"title",
|
||||
"url",
|
||||
"creator",
|
||||
"post_views",
|
||||
"rating",
|
||||
"comment_count",
|
||||
"date_pub",
|
||||
)
|
||||
|
||||
|
||||
class FullPostSerializer(serializers.ModelSerializer):
|
||||
creator = UserPublicInfoSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Post
|
||||
fields = (
|
||||
"id",
|
||||
"slug",
|
||||
"title",
|
||||
"body",
|
||||
"creator",
|
||||
"post_views",
|
||||
"rating",
|
||||
"rating_count",
|
||||
"comment_count",
|
||||
"date_pub",
|
||||
"edited",
|
||||
"image",
|
||||
)
|
||||
extra_kwargs = {
|
||||
"id": {"read_only": True},
|
||||
"slug": {"read_only": True},
|
||||
"creator": {"read_only": True},
|
||||
"post_views": {"read_only": True},
|
||||
"rating": {"read_only": True},
|
||||
"rating_count": {"read_only": True},
|
||||
"comment_count": {"read_only": True},
|
||||
"date_pub": {"read_only": True},
|
||||
"edited": {"read_only": True},
|
||||
}
|
||||
|
||||
def create(self, validated_data):
|
||||
return Post.objects.create(
|
||||
**validated_data, creator=self.context["request"].user
|
||||
)
|
||||
|
||||
|
||||
class CommentSerializer(serializers.ModelSerializer):
|
||||
author = UserPublicInfoSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = ("id", "author", "body", "created", "rating")
|
||||
extra_kwargs = {
|
||||
"id": {"read_only": True},
|
||||
"author": {"read_only": True},
|
||||
"created": {"read_only": True},
|
||||
"rating": {"read_only": True},
|
||||
}
|
||||
|
||||
def create(self, validated_data):
|
||||
return Comment.objects.create(
|
||||
**validated_data,
|
||||
author=self.context["request"].user,
|
||||
post=Post.objects.get(
|
||||
slug=self.context.get("request")
|
||||
.parser_context.get("kwargs")
|
||||
.get("slug")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class UpvoteCommentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = CommentRating
|
||||
fields = ("vote_up",)
|
||||
|
||||
def create(self, validated_data):
|
||||
comment = get_object_or_404(
|
||||
Comment,
|
||||
id=self.context.get("request").parser_context.get("kwargs").get("pk"),
|
||||
)
|
||||
return update_comment_rate(
|
||||
comment, self.context["request"].user, validated_data["vote_up"]
|
||||
)
|
||||
|
||||
|
||||
class PostRateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PostRating
|
||||
fields = ("rating",)
|
||||
|
||||
def create(self, validated_data):
|
||||
return update_post_rating(
|
||||
post=get_object_or_404(
|
||||
Post,
|
||||
slug=self.context.get("request")
|
||||
.parser_context.get("kwargs")
|
||||
.get("slug"),
|
||||
),
|
||||
user=self.context["request"].user,
|
||||
rating=validated_data["rating"],
|
||||
)
|
188
akarpov/blog/api/views.py
Normal file
188
akarpov/blog/api/views.py
Normal file
|
@ -0,0 +1,188 @@
|
|||
from django.db.models import F
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import generics, status
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
from akarpov.blog.api.serializers import (
|
||||
CommentSerializer,
|
||||
FullPostSerializer,
|
||||
ListPostSerializer,
|
||||
PostRateSerializer,
|
||||
UpvoteCommentSerializer,
|
||||
)
|
||||
from akarpov.blog.models import Comment, CommentRating, Post, PostRating
|
||||
from akarpov.common.api import SmallResultsSetPagination
|
||||
|
||||
|
||||
class ListPostsApiView(generics.ListAPIView):
|
||||
serializer_class = ListPostSerializer
|
||||
pagination_class = SmallResultsSetPagination
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
queryset = Post.objects.get_queryset().order_by("id")
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return self.list(request, *args, **kwargs)
|
||||
|
||||
|
||||
class CreatePostApiView(generics.CreateAPIView):
|
||||
serializer_class = FullPostSerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
return self.create(request, *args, **kwargs)
|
||||
|
||||
|
||||
class GetUpdateDeletePostApiView(generics.RetrieveUpdateDestroyAPIView):
|
||||
serializer_class = FullPostSerializer
|
||||
lookup_field = "slug"
|
||||
|
||||
queryset = Post.objects.all()
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "GET":
|
||||
return [AllowAny]
|
||||
return [IsAuthenticated]
|
||||
|
||||
authentication_classes = [JWTAuthentication]
|
||||
|
||||
def get_object(self):
|
||||
if self.request.method != "GET":
|
||||
if super().get_object().creator != self.request.user:
|
||||
raise AuthenticationFailed("you are not allowed to access this post")
|
||||
return super().get_object()
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
post = self.get_object()
|
||||
post.post_views = F("post_views") + 1
|
||||
post.save(update_fields=["post_views"])
|
||||
|
||||
return self.retrieve(request, *args, **kwargs)
|
||||
|
||||
def put(self, request, *args, **kwargs):
|
||||
return self.update(request, *args, **kwargs)
|
||||
|
||||
def patch(self, request, *args, **kwargs):
|
||||
return self.partial_update(request, *args, **kwargs)
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
return self.destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
class ListCreateCommentApiView(generics.ListCreateAPIView):
|
||||
serializer_class = CommentSerializer
|
||||
pagination_class = SmallResultsSetPagination
|
||||
|
||||
authentication_classes = [JWTAuthentication]
|
||||
|
||||
def get_queryset(self):
|
||||
return Comment.objects.filter(post__slug=self.kwargs["slug"])
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "GET":
|
||||
return [AllowAny]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return self.list(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
return self.create(request, *args, **kwargs)
|
||||
|
||||
|
||||
class RetrieveUpdateDeleteCommentApiView(generics.RetrieveUpdateDestroyAPIView):
|
||||
serializer_class = CommentSerializer
|
||||
lookup_field = "pk"
|
||||
|
||||
queryset = Comment.objects.all()
|
||||
authentication_classes = [JWTAuthentication]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "GET":
|
||||
return [AllowAny]
|
||||
return [IsAuthenticated]
|
||||
|
||||
def get_object(self):
|
||||
if self.request.method != "GET":
|
||||
if super().get_object().author != self.request.user:
|
||||
raise AuthenticationFailed("you are not allowed to access this comment")
|
||||
return super().get_object()
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return self.retrieve(request, *args, **kwargs)
|
||||
|
||||
def put(self, request, *args, **kwargs):
|
||||
return self.update(request, *args, **kwargs)
|
||||
|
||||
def patch(self, request, *args, **kwargs):
|
||||
return self.partial_update(request, *args, **kwargs)
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
return self.destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
class CreateDeleteCommentRateApiView(generics.CreateAPIView):
|
||||
serializer_class = UpvoteCommentSerializer
|
||||
queryset = CommentRating.objects.all()
|
||||
|
||||
authentication_classes = [JWTAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
return serializer.save()
|
||||
|
||||
@extend_schema(responses={200: CommentSerializer()})
|
||||
def post(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
|
||||
comment = CommentSerializer(
|
||||
Comment.objects.get(id=self.kwargs["pk"]), context={"request": request}
|
||||
)
|
||||
return Response(
|
||||
data=comment.data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@extend_schema(responses={200: CommentSerializer()})
|
||||
def delete(self, request, *args, **kwargs):
|
||||
CommentRating.objects.filter(
|
||||
user=request.user, comment__id=self.kwargs["pk"]
|
||||
).delete()
|
||||
|
||||
comment = CommentSerializer(
|
||||
Comment.objects.get(id=self.kwargs["pk"]), context={"request": request}
|
||||
)
|
||||
return Response(
|
||||
data=comment.data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class CreateDeletePostRating(generics.CreateAPIView, generics.DestroyAPIView):
|
||||
serializer_class = PostRateSerializer
|
||||
queryset = PostRating.objects.all()
|
||||
|
||||
def get_post(self):
|
||||
return get_object_or_404(Post, slug=self.kwargs["slug"])
|
||||
|
||||
def get_object(self):
|
||||
try:
|
||||
return PostRating.objects.get(post=self.get_post(), user=self.request.user)
|
||||
except PostRating.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@extend_schema(request=PostRateSerializer, responses={200: ListPostSerializer()})
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.create(request, *args, **kwargs)
|
||||
post = ListPostSerializer(self.get_post(), context={"request": request})
|
||||
return Response(post.data, status=status.HTTP_200_OK)
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
self.destroy(request, *args, **kwargs)
|
||||
post = ListPostSerializer(self.get_post(), context={"request": request})
|
||||
return Response(post.data, status=status.HTTP_200_OK)
|
13
akarpov/blog/apps.py
Normal file
13
akarpov/blog/apps.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class BlogConfig(AppConfig):
|
||||
verbose_name = _("Blog")
|
||||
name = "akarpov.blog"
|
||||
|
||||
def ready(self):
|
||||
try:
|
||||
import akarpov.blog.signals # noqa F401
|
||||
except ImportError:
|
||||
pass
|
79
akarpov/blog/migrations/0001_initial.py
Normal file
79
akarpov/blog/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,79 @@
|
|||
# Generated by Django 4.0.8 on 2022-11-16 20:00
|
||||
|
||||
import akarpov.utils.files
|
||||
from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Post',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(db_index=True, max_length=120)),
|
||||
('slug', models.SlugField(max_length=150, unique=True)),
|
||||
('body', models.TextField(db_index=True)),
|
||||
('post_views', models.IntegerField(default=0)),
|
||||
('rating', models.FloatField(default=0, validators=[django.core.validators.MaxValueValidator(5), django.core.validators.MinValueValidator(0)])),
|
||||
('rating_exactly', models.IntegerField(default=0)),
|
||||
('rating_count', models.IntegerField(default=0)),
|
||||
('comment_count', models.IntegerField(default=0)),
|
||||
('date_pub', models.DateTimeField(auto_now_add=True)),
|
||||
('edited', models.DateTimeField(auto_now=True)),
|
||||
('image', models.ImageField(blank=True, upload_to=akarpov.utils.files.user_file_upload_mixin)),
|
||||
('image_cropped', models.ImageField(blank=True, upload_to='cropped/')),
|
||||
('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Comment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('body', models.CharField(max_length=500)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('rating', models.IntegerField(default=0)),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL)),
|
||||
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='blog.post')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-rating'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PostRating',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('rating', models.IntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)])),
|
||||
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='blog.post')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_ratings', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'post')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CommentRating',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('vote_up', models.BooleanField()),
|
||||
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='blog.comment')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_ratings', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('comment', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
0
akarpov/blog/migrations/__init__.py
Normal file
0
akarpov/blog/migrations/__init__.py
Normal file
84
akarpov/blog/models.py
Normal file
84
akarpov/blog/models.py
Normal file
|
@ -0,0 +1,84 @@
|
|||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
|
||||
from akarpov.users.models import User
|
||||
from akarpov.utils.files import user_file_upload_mixin
|
||||
|
||||
|
||||
class Post(models.Model):
|
||||
"""Model to store user's posts"""
|
||||
|
||||
title = models.CharField(max_length=120, db_index=True)
|
||||
slug = models.SlugField(max_length=150, blank=False, unique=True)
|
||||
body = models.TextField(blank=False, db_index=True)
|
||||
creator = models.ForeignKey(User, on_delete=models.CASCADE, related_name="posts")
|
||||
|
||||
post_views = models.IntegerField(default=0)
|
||||
rating = models.FloatField(
|
||||
default=0, validators=[MaxValueValidator(5), MinValueValidator(0)]
|
||||
)
|
||||
rating_exactly = models.IntegerField(default=0)
|
||||
rating_count = models.IntegerField(default=0)
|
||||
comment_count = models.IntegerField(default=0)
|
||||
|
||||
date_pub = models.DateTimeField(auto_now_add=True)
|
||||
edited = models.DateTimeField(auto_now=True)
|
||||
|
||||
image = models.ImageField(upload_to=user_file_upload_mixin, blank=True)
|
||||
image_cropped = models.ImageField(upload_to="cropped/", blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Meta:
|
||||
ordering = ["id"]
|
||||
|
||||
|
||||
class PostRating(models.Model):
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, related_name="post_ratings"
|
||||
)
|
||||
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="ratings")
|
||||
|
||||
rating = models.IntegerField(
|
||||
validators=[MinValueValidator(1), MaxValueValidator(5)]
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username}'s rating {self.rating} on {self.post.title}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["user", "post"]
|
||||
|
||||
|
||||
class Comment(models.Model):
|
||||
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
|
||||
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="comments")
|
||||
|
||||
body = models.CharField(max_length=500)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
rating = models.IntegerField(default=0)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.author.username}'s comment on {self.post.title}"
|
||||
|
||||
class Meta:
|
||||
ordering = ["-rating"]
|
||||
|
||||
|
||||
class CommentRating(models.Model):
|
||||
comment = models.ForeignKey(
|
||||
Comment, on_delete=models.CASCADE, related_name="ratings"
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, related_name="comment_ratings"
|
||||
)
|
||||
|
||||
vote_up = models.BooleanField(blank=False)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user}'s vote up" if self.vote_up else f"{self.user}'s vote down"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["comment", "user"]
|
0
akarpov/blog/services/__init__.py
Normal file
0
akarpov/blog/services/__init__.py
Normal file
65
akarpov/blog/services/post.py
Normal file
65
akarpov/blog/services/post.py
Normal file
|
@ -0,0 +1,65 @@
|
|||
from akarpov.blog.models import Comment, CommentRating, Post, PostRating
|
||||
from akarpov.users.models import User
|
||||
|
||||
|
||||
def update_comment_rate(comment: Comment, user: User, vote_up: bool):
|
||||
"""Creates and updates comments' rating"""
|
||||
comment_rate = CommentRating.objects.filter(user=user, comment=comment)
|
||||
|
||||
if comment_rate.exists():
|
||||
comment_rate = comment_rate[0]
|
||||
|
||||
if comment_rate.vote_up == vote_up:
|
||||
return comment_rate
|
||||
elif vote_up:
|
||||
comment_rate.comment.rating += 2
|
||||
else:
|
||||
comment_rate.comment.rating -= 2
|
||||
comment_rate.vote_up = vote_up
|
||||
|
||||
comment_rate.comment.save(update_fields=["rating"])
|
||||
comment_rate.save(update_fields=["vote_up"])
|
||||
return comment_rate
|
||||
else:
|
||||
comment_rate = CommentRating.objects.create(
|
||||
user=user, comment=comment, vote_up=vote_up
|
||||
)
|
||||
if vote_up:
|
||||
comment_rate.comment.rating += 1
|
||||
else:
|
||||
comment_rate.comment.rating -= 1
|
||||
|
||||
comment_rate.comment.save(update_fields=["rating"])
|
||||
|
||||
return comment_rate
|
||||
|
||||
|
||||
def update_post_rating(post: Post, user: User, rating: int):
|
||||
"""Creates and updates posts' rating"""
|
||||
if old_rate := PostRating.objects.filter(
|
||||
post=post,
|
||||
user=user,
|
||||
):
|
||||
old_rate = old_rate[0]
|
||||
if old_rate.rating != rating:
|
||||
|
||||
rating_ex = post.rating_exactly - old_rate.rating + rating
|
||||
post.rating_exactly = rating_ex
|
||||
post.rating = round(rating_ex / post.rating_count, 2)
|
||||
old_rate.rating = rating
|
||||
|
||||
post.save(update_fields=["rating_count", "rating_exactly", "rating"])
|
||||
old_rate.save(update_fields=["rating"])
|
||||
return old_rate
|
||||
else:
|
||||
rating_ex = post.rating_exactly + rating
|
||||
post.rating_exactly = rating_ex
|
||||
post.rating = round(rating_ex / (post.rating_count + 1), 2)
|
||||
post.rating_count += 1
|
||||
|
||||
post.save(update_fields=["rating_count", "rating_exactly", "rating"])
|
||||
return PostRating.objects.create(
|
||||
rating=rating,
|
||||
post=post,
|
||||
user=user,
|
||||
)
|
71
akarpov/blog/signals.py
Normal file
71
akarpov/blog/signals.py
Normal file
|
@ -0,0 +1,71 @@
|
|||
from django.core.files import File
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from akarpov.blog.models import Comment, CommentRating, Post, PostRating
|
||||
from akarpov.utils.files import crop_image
|
||||
from akarpov.utils.generators import generate_charset
|
||||
|
||||
|
||||
@receiver(post_save, sender=Post)
|
||||
def create_post(sender, instance, created, **kwargs):
|
||||
if kwargs["update_fields"] != {"post_views"}:
|
||||
if created:
|
||||
slug = generate_charset(4)
|
||||
while Post.objects.filter(slug=slug).exists():
|
||||
slug = generate_charset(4)
|
||||
|
||||
instance.slug = slug
|
||||
instance.save(update_fields=["slug"])
|
||||
|
||||
if instance.image:
|
||||
instance.image_cropped.save(
|
||||
instance.image.path.split(".")[0].split("/")[-1] + ".png",
|
||||
File(crop_image(instance.image.path, cut_to=(750, 250))),
|
||||
save=False,
|
||||
)
|
||||
|
||||
post_save.disconnect(create_post, sender=sender)
|
||||
instance.save(update_fields=["image_cropped"])
|
||||
post_save.connect(create_post, sender=Post)
|
||||
|
||||
|
||||
# comments
|
||||
|
||||
|
||||
@receiver(post_save, sender=Comment)
|
||||
def create_comment(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
instance.post.comment_count += 1
|
||||
instance.post.save(update_fields=["comment_count"])
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Comment)
|
||||
def delete_comment(sender, instance, **kwargs):
|
||||
instance.post.comment_count -= 1
|
||||
instance.post.save(update_fields=["comment_count"])
|
||||
|
||||
|
||||
@receiver(post_delete, sender=CommentRating)
|
||||
def delete_comment_rating(sender, instance, **kwargs):
|
||||
if instance.vote_up:
|
||||
instance.comment.rating -= 1
|
||||
else:
|
||||
instance.comment.rating += 1
|
||||
|
||||
instance.comment.save(update_fields=["rating"])
|
||||
|
||||
|
||||
@receiver(post_delete, sender=PostRating)
|
||||
def delete_post_rating(sender, instance, **kwargs):
|
||||
if instance.post.rating_count != 1:
|
||||
rating = instance.post.rating_exactly - instance.rating
|
||||
instance.post.rating_exactly -= instance.rating
|
||||
instance.post.rating = round(rating / (instance.post.rating_count - 1), 2)
|
||||
instance.post.rating_count -= 1
|
||||
else:
|
||||
instance.post.rating_exactly = 0
|
||||
instance.post.rating = 0
|
||||
instance.post.rating_count = 0
|
||||
|
||||
instance.post.save(update_fields=["rating_count", "rating_exactly", "rating"])
|
0
akarpov/common/__init__.py
Normal file
0
akarpov/common/__init__.py
Normal file
19
akarpov/common/api.py
Normal file
19
akarpov/common/api.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
|
||||
class SmallResultsSetPagination(PageNumberPagination):
|
||||
page_size = 10
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 50
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 200
|
||||
|
||||
|
||||
class BigResultsSetPagination(PageNumberPagination):
|
||||
page_size = 100
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 1000
|
14
akarpov/conftest.py
Normal file
14
akarpov/conftest.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
import pytest
|
||||
|
||||
from akarpov.users.models import User
|
||||
from akarpov.users.tests.factories import UserFactory
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def media_storage(settings, tmpdir):
|
||||
settings.MEDIA_ROOT = tmpdir.strpath
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db) -> User:
|
||||
return UserFactory()
|
5
akarpov/contrib/__init__.py
Normal file
5
akarpov/contrib/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
To understand why this file is here, please read:
|
||||
|
||||
http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django
|
||||
"""
|
5
akarpov/contrib/sites/__init__.py
Normal file
5
akarpov/contrib/sites/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
To understand why this file is here, please read:
|
||||
|
||||
http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django
|
||||
"""
|
42
akarpov/contrib/sites/migrations/0001_initial.py
Normal file
42
akarpov/contrib/sites/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
import django.contrib.sites.models
|
||||
from django.contrib.sites.models import _simple_domain_name_validator
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Site",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
verbose_name="ID",
|
||||
serialize=False,
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"domain",
|
||||
models.CharField(
|
||||
max_length=100,
|
||||
verbose_name="domain name",
|
||||
validators=[_simple_domain_name_validator],
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=50, verbose_name="display name")),
|
||||
],
|
||||
options={
|
||||
"ordering": ("domain",),
|
||||
"db_table": "django_site",
|
||||
"verbose_name": "site",
|
||||
"verbose_name_plural": "sites",
|
||||
},
|
||||
bases=(models.Model,),
|
||||
managers=[("objects", django.contrib.sites.models.SiteManager())],
|
||||
)
|
||||
]
|
20
akarpov/contrib/sites/migrations/0002_alter_domain_unique.py
Normal file
20
akarpov/contrib/sites/migrations/0002_alter_domain_unique.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
import django.contrib.sites.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("sites", "0001_initial")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="site",
|
||||
name="domain",
|
||||
field=models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
validators=[django.contrib.sites.models._simple_domain_name_validator],
|
||||
verbose_name="domain name",
|
||||
),
|
||||
)
|
||||
]
|
|
@ -0,0 +1,63 @@
|
|||
"""
|
||||
To understand why this file is here, please read:
|
||||
|
||||
http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def _update_or_create_site_with_sequence(site_model, connection, domain, name):
|
||||
"""Update or create the site with default ID and keep the DB sequence in sync."""
|
||||
site, created = site_model.objects.update_or_create(
|
||||
id=settings.SITE_ID,
|
||||
defaults={
|
||||
"domain": domain,
|
||||
"name": name,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
# We provided the ID explicitly when creating the Site entry, therefore the DB
|
||||
# sequence to auto-generate them wasn't used and is now out of sync. If we
|
||||
# don't do anything, we'll get a unique constraint violation the next time a
|
||||
# site is created.
|
||||
# To avoid this, we need to manually update DB sequence and make sure it's
|
||||
# greater than the maximum value.
|
||||
max_id = site_model.objects.order_by('-id').first().id
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT last_value from django_site_id_seq")
|
||||
(current_id,) = cursor.fetchone()
|
||||
if current_id <= max_id:
|
||||
cursor.execute(
|
||||
"alter sequence django_site_id_seq restart with %s",
|
||||
[max_id + 1],
|
||||
)
|
||||
|
||||
|
||||
def update_site_forward(apps, schema_editor):
|
||||
"""Set site domain and name."""
|
||||
Site = apps.get_model("sites", "Site")
|
||||
_update_or_create_site_with_sequence(
|
||||
Site,
|
||||
schema_editor.connection,
|
||||
"akarpov.ru",
|
||||
"akarpov",
|
||||
)
|
||||
|
||||
|
||||
def update_site_backward(apps, schema_editor):
|
||||
"""Revert site domain and name to default."""
|
||||
Site = apps.get_model("sites", "Site")
|
||||
_update_or_create_site_with_sequence(
|
||||
Site,
|
||||
schema_editor.connection,
|
||||
"example.com",
|
||||
"example.com",
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("sites", "0002_alter_domain_unique")]
|
||||
|
||||
operations = [migrations.RunPython(update_site_forward, update_site_backward)]
|
|
@ -0,0 +1,21 @@
|
|||
# Generated by Django 3.1.7 on 2021-02-04 14:49
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("sites", "0003_set_site_domain_and_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="site",
|
||||
options={
|
||||
"ordering": ["domain"],
|
||||
"verbose_name": "site",
|
||||
"verbose_name_plural": "sites",
|
||||
},
|
||||
),
|
||||
]
|
5
akarpov/contrib/sites/migrations/__init__.py
Normal file
5
akarpov/contrib/sites/migrations/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
To understand why this file is here, please read:
|
||||
|
||||
http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django
|
||||
"""
|
13
akarpov/static/css/project.css
Normal file
13
akarpov/static/css/project.css
Normal file
|
@ -0,0 +1,13 @@
|
|||
/* These styles are generated from project.scss. */
|
||||
|
||||
.alert-debug {
|
||||
color: black;
|
||||
background-color: white;
|
||||
border-color: #d6e9c6;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
color: #b94a48;
|
||||
background-color: #f2dede;
|
||||
border-color: #eed3d7;
|
||||
}
|
0
akarpov/static/fonts/.gitkeep
Normal file
0
akarpov/static/fonts/.gitkeep
Normal file
BIN
akarpov/static/images/favicons/favicon.ico
Normal file
BIN
akarpov/static/images/favicons/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.2 KiB |
1
akarpov/static/js/project.js
Normal file
1
akarpov/static/js/project.js
Normal file
|
@ -0,0 +1 @@
|
|||
/* Project specific Javascript goes here. */
|
9
akarpov/templates/403.html
Normal file
9
akarpov/templates/403.html
Normal file
|
@ -0,0 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Forbidden (403){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Forbidden (403)</h1>
|
||||
|
||||
<p>{% if exception %}{{ exception }}{% else %}You're not allowed to access this page.{% endif %}</p>
|
||||
{% endblock content %}
|
9
akarpov/templates/404.html
Normal file
9
akarpov/templates/404.html
Normal file
|
@ -0,0 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Page not found{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Page not found</h1>
|
||||
|
||||
<p>{% if exception %}{{ exception }}{% else %}This is not the page you were looking for.{% endif %}</p>
|
||||
{% endblock content %}
|
11
akarpov/templates/500.html
Normal file
11
akarpov/templates/500.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Server Error{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Ooops!!! 500</h1>
|
||||
|
||||
<h3>Looks like something went wrong!</h3>
|
||||
|
||||
<p>We track these errors automatically, but if the problem persists feel free to contact us. In the meantime, try refreshing.</p>
|
||||
{% endblock content %}
|
11
akarpov/templates/account/account_inactive.html
Normal file
11
akarpov/templates/account/account_inactive.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% extends "account/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block head_title %}{% translate "Account Inactive" %}{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
<h1>{% translate "Account Inactive" %}</h1>
|
||||
|
||||
<p>{% translate "This account is inactive." %}</p>
|
||||
{% endblock %}
|
10
akarpov/templates/account/base.html
Normal file
10
akarpov/templates/account/base.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{% block head_title %}{% endblock head_title %}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
{% block inner %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
78
akarpov/templates/account/email.html
Normal file
78
akarpov/templates/account/email.html
Normal file
|
@ -0,0 +1,78 @@
|
|||
|
||||
{% extends "account/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block head_title %}{% translate "Account" %}{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
<h1>{% translate "E-mail Addresses" %}</h1>
|
||||
|
||||
{% if user.emailaddress_set.all %}
|
||||
<p>{% translate 'The following e-mail addresses are associated with your account:' %}</p>
|
||||
|
||||
<form action="{% url 'account_email' %}" class="email_list" method="post">
|
||||
{% csrf_token %}
|
||||
<fieldset class="blockLabels">
|
||||
|
||||
{% for emailaddress in user.emailaddress_set.all %}
|
||||
<div class="radio">
|
||||
<label for="email_radio_{{forloop.counter}}" class="{% if emailaddress.primary %}primary_email{%endif%}">
|
||||
|
||||
<input id="email_radio_{{forloop.counter}}" type="radio" name="email" {% if emailaddress.primary or user.emailaddress_set.count == 1 %}checked="checked"{%endif %} value="{{emailaddress.email}}"/>
|
||||
|
||||
{{ emailaddress.email }}
|
||||
{% if emailaddress.verified %}
|
||||
<span class="verified">{% translate "Verified" %}</span>
|
||||
{% else %}
|
||||
<span class="unverified">{% translate "Unverified" %}</span>
|
||||
{% endif %}
|
||||
{% if emailaddress.primary %}<span class="primary">{% translate "Primary" %}</span>{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="form-group">
|
||||
<button class="secondaryAction btn btn-primary" type="submit" name="action_primary" >{% translate 'Make Primary' %}</button>
|
||||
<button class="secondaryAction btn btn-primary" type="submit" name="action_send" >{% translate 'Re-send Verification' %}</button>
|
||||
<button class="primaryAction btn btn-primary" type="submit" name="action_remove" >{% translate 'Remove' %}</button>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
{% else %}
|
||||
<p><strong>{% translate 'Warning:'%}</strong> {% translate "You currently do not have any e-mail address set up. You should really add an e-mail address so you can receive notifications, reset your password, etc." %}</p>
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
<h2>{% translate "Add E-mail Address" %}</h2>
|
||||
|
||||
<form method="post" action="{% url 'account_email' %}" class="add_email">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-primary" name="action_add" type="submit">{% translate "Add E-mail" %}</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block inline_javascript %}
|
||||
{{ block.super }}
|
||||
<script type="text/javascript">
|
||||
window.addEventListener('DOMContentLoaded',function() {
|
||||
const message = "{% translate 'Do you really want to remove the selected e-mail address?' %}";
|
||||
const actions = document.getElementsByName('action_remove');
|
||||
if (actions.length) {
|
||||
actions[0].addEventListener("click",function(e) {
|
||||
if (!confirm(message)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
Array.from(document.getElementsByClassName('form-group')).forEach(x => x.classList.remove('row'));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
31
akarpov/templates/account/email_confirm.html
Normal file
31
akarpov/templates/account/email_confirm.html
Normal file
|
@ -0,0 +1,31 @@
|
|||
{% extends "account/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load account %}
|
||||
|
||||
{% block head_title %}{% translate "Confirm E-mail Address" %}{% endblock %}
|
||||
|
||||
|
||||
{% block inner %}
|
||||
<h1>{% translate "Confirm E-mail Address" %}</h1>
|
||||
|
||||
{% if confirmation %}
|
||||
|
||||
{% user_display confirmation.email_address.user as user_display %}
|
||||
|
||||
<p>{% blocktranslate with confirmation.email_address.email as email %}Please confirm that <a href="mailto:{{ email }}">{{ email }}</a> is an e-mail address for user {{ user_display }}.{% endblocktranslate %}</p>
|
||||
|
||||
<form method="post" action="{% url 'account_confirm_email' confirmation.key %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-primary" type="submit">{% translate 'Confirm' %}</button>
|
||||
</form>
|
||||
|
||||
{% else %}
|
||||
|
||||
{% url 'account_email' as email_url %}
|
||||
|
||||
<p>{% blocktranslate %}This e-mail confirmation link expired or is invalid. Please <a href="{{ email_url }}">issue a new e-mail confirmation request</a>.{% endblocktranslate %}</p>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
59
akarpov/templates/account/login.html
Normal file
59
akarpov/templates/account/login.html
Normal file
|
@ -0,0 +1,59 @@
|
|||
{% extends "account/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load account socialaccount %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block head_title %}{% translate "Sign In" %}{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
|
||||
<h1>{% translate "Sign In" %}</h1>
|
||||
|
||||
{% get_providers as socialaccount_providers %}
|
||||
|
||||
{% if socialaccount_providers %}
|
||||
<p>
|
||||
{% translate "Please sign in with one of your existing third party accounts:" %}
|
||||
{% if ACCOUNT_ALLOW_REGISTRATION %}
|
||||
{% blocktranslate trimmed %}
|
||||
Or, <a href="{{ signup_url }}">sign up</a>
|
||||
for a {{ site_name }} account and sign in below:
|
||||
{% endblocktranslate %}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="socialaccount_ballot">
|
||||
|
||||
<ul class="socialaccount_providers">
|
||||
{% include "socialaccount/snippets/provider_list.html" with process="login" %}
|
||||
</ul>
|
||||
|
||||
<div class="login-or">{% translate "or" %}</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% include "socialaccount/snippets/login_extra.html" %}
|
||||
|
||||
{% else %}
|
||||
{% if ACCOUNT_ALLOW_REGISTRATION %}
|
||||
<p>
|
||||
{% blocktranslate trimmed %}
|
||||
If you have not created an account yet, then please
|
||||
<a href="{{ signup_url }}">sign up</a> first.
|
||||
{% endblocktranslate %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<form class="login" method="POST" action="{% url 'account_login' %}">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
{% if redirect_field_value %}
|
||||
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}" />
|
||||
{% endif %}
|
||||
<a class="button secondaryAction" href="{% url 'account_reset_password' %}">{% translate "Forgot Password?" %}</a>
|
||||
<button class="primaryAction btn btn-primary" type="submit">{% translate "Sign In" %}</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
19
akarpov/templates/account/logout.html
Normal file
19
akarpov/templates/account/logout.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
{% extends "account/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block head_title %}{% translate "Sign Out" %}{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
<h1>{% translate "Sign Out" %}</h1>
|
||||
|
||||
<p>{% translate 'Are you sure you want to sign out?' %}</p>
|
||||
|
||||
<form method="post" action="{% url 'account_logout' %}">
|
||||
{% csrf_token %}
|
||||
{% if redirect_field_value %}
|
||||
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}"/>
|
||||
{% endif %}
|
||||
<button class="btn btn-danger" type="submit">{% translate 'Sign Out' %}</button>
|
||||
</form>
|
||||
{% endblock %}
|
16
akarpov/templates/account/password_change.html
Normal file
16
akarpov/templates/account/password_change.html
Normal file
|
@ -0,0 +1,16 @@
|
|||
{% extends "account/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block head_title %}{% translate "Change Password" %}{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
<h1>{% translate "Change Password" %}</h1>
|
||||
|
||||
<form method="POST" action="{% url 'account_change_password' %}" class="password_change">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-primary" type="submit" name="action">{% translate "Change Password" %}</button>
|
||||
</form>
|
||||
{% endblock %}
|
25
akarpov/templates/account/password_reset.html
Normal file
25
akarpov/templates/account/password_reset.html
Normal file
|
@ -0,0 +1,25 @@
|
|||
{% extends "account/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load account %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block head_title %}{% translate "Password Reset" %}{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
|
||||
<h1>{% translate "Password Reset" %}</h1>
|
||||
{% if user.is_authenticated %}
|
||||
{% include "account/snippets/already_logged_in.html" %}
|
||||
{% endif %}
|
||||
|
||||
<p>{% translate "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." %}</p>
|
||||
|
||||
<form method="POST" action="{% url 'account_reset_password' %}" class="password_reset">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<input class="btn btn-primary" type="submit" value="{% translate 'Reset My Password' %}" />
|
||||
</form>
|
||||
|
||||
<p>{% blocktranslate %}Please contact us if you have any trouble resetting your password.{% endblocktranslate %}</p>
|
||||
{% endblock %}
|
16
akarpov/templates/account/password_reset_done.html
Normal file
16
akarpov/templates/account/password_reset_done.html
Normal file
|
@ -0,0 +1,16 @@
|
|||
{% extends "account/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load account %}
|
||||
|
||||
{% block head_title %}{% translate "Password Reset" %}{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
<h1>{% translate "Password Reset" %}</h1>
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
{% include "account/snippets/already_logged_in.html" %}
|
||||
{% endif %}
|
||||
|
||||
<p>{% blocktranslate %}We have sent you an e-mail. Please contact us if you do not receive it within a few minutes.{% endblocktranslate %}</p>
|
||||
{% endblock %}
|
24
akarpov/templates/account/password_reset_from_key.html
Normal file
24
akarpov/templates/account/password_reset_from_key.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends "account/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% block head_title %}{% translate "Change Password" %}{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
<h1>{% if token_fail %}{% translate "Bad Token" %}{% else %}{% translate "Change Password" %}{% endif %}</h1>
|
||||
|
||||
{% if token_fail %}
|
||||
{% url 'account_reset_password' as passwd_reset_url %}
|
||||
<p>{% blocktranslate %}The password reset link was invalid, possibly because it has already been used. Please request a <a href="{{ passwd_reset_url }}">new password reset</a>.{% endblocktranslate %}</p>
|
||||
{% else %}
|
||||
{% if form %}
|
||||
<form method="POST" action=".">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<input class="btn btn-primary" type="submit" name="action" value="{% translate 'change password' %}"/>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>{% translate 'Your password is now changed.' %}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,9 @@
|
|||
{% extends "account/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% block head_title %}{% translate "Change Password" %}{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
<h1>{% translate "Change Password" %}</h1>
|
||||
<p>{% translate 'Your password is now changed.' %}</p>
|
||||
{% endblock %}
|
16
akarpov/templates/account/password_set.html
Normal file
16
akarpov/templates/account/password_set.html
Normal file
|
@ -0,0 +1,16 @@
|
|||
{% extends "account/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block head_title %}{% translate "Set Password" %}{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
<h1>{% translate "Set Password" %}</h1>
|
||||
|
||||
<form method="POST" action="{% url 'account_set_password' %}" class="password_set">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<input class="btn btn-primary" type="submit" name="action" value="{% translate 'Set Password' %}"/>
|
||||
</form>
|
||||
{% endblock %}
|
22
akarpov/templates/account/signup.html
Normal file
22
akarpov/templates/account/signup.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
{% extends "account/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block head_title %}{% translate "Signup" %}{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
<h1>{% translate "Sign Up" %}</h1>
|
||||
|
||||
<p>{% blocktranslate %}Already have an account? Then please <a href="{{ login_url }}">sign in</a>.{% endblocktranslate %}</p>
|
||||
|
||||
<form class="signup" id="signup_form" method="post" action="{% url 'account_signup' %}">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
{% if redirect_field_value %}
|
||||
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}" />
|
||||
{% endif %}
|
||||
<button class="btn btn-primary" type="submit">{% translate "Sign Up" %} »</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
11
akarpov/templates/account/signup_closed.html
Normal file
11
akarpov/templates/account/signup_closed.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% extends "account/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block head_title %}{% translate "Sign Up Closed" %}{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
<h1>{% translate "Sign Up Closed" %}</h1>
|
||||
|
||||
<p>{% translate "We are sorry, but the sign up is currently closed." %}</p>
|
||||
{% endblock %}
|
12
akarpov/templates/account/verification_sent.html
Normal file
12
akarpov/templates/account/verification_sent.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
{% extends "account/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block head_title %}{% translate "Verify Your E-mail Address" %}{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
<h1>{% translate "Verify Your E-mail Address" %}</h1>
|
||||
|
||||
<p>{% blocktranslate %}We have sent an e-mail to you for verification. Follow the link provided to finalize the signup process. Please contact us if you do not receive it within a few minutes.{% endblocktranslate %}</p>
|
||||
|
||||
{% endblock %}
|
21
akarpov/templates/account/verified_email_required.html
Normal file
21
akarpov/templates/account/verified_email_required.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
{% extends "account/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block head_title %}{% translate "Verify Your E-mail Address" %}{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
<h1>{% translate "Verify Your E-mail Address" %}</h1>
|
||||
|
||||
{% url 'account_email' as email_url %}
|
||||
|
||||
<p>{% blocktranslate %}This part of the site requires us to verify that
|
||||
you are who you claim to be. For this purpose, we require that you
|
||||
verify ownership of your e-mail address. {% endblocktranslate %}</p>
|
||||
|
||||
<p>{% blocktranslate %}We have sent an e-mail to you for
|
||||
verification. Please click on the link inside this e-mail. Please
|
||||
contact us if you do not receive it within a few minutes.{% endblocktranslate %}</p>
|
||||
|
||||
<p>{% blocktranslate %}<strong>Note:</strong> you can still <a href="{{ email_url }}">change your e-mail address</a>.{% endblocktranslate %}</p>
|
||||
{% endblock %}
|
112
akarpov/templates/base.html
Normal file
112
akarpov/templates/base.html
Normal file
|
@ -0,0 +1,112 @@
|
|||
{% load static i18n %}<!DOCTYPE html>
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
<html lang="{{ LANGUAGE_CODE }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<title>{% block title %}akarpov{% endblock title %}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="my collection of tools">
|
||||
<meta name="author" content="sanspie">
|
||||
|
||||
<link rel="icon" href="{% static 'images/favicons/favicon.ico' %}">
|
||||
|
||||
{% block css %}
|
||||
<!-- Latest compiled and minified Bootstrap CSS -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/css/bootstrap.min.css" integrity="sha512-GQGU0fMMi238uA+a/bdWJfpUGKUkBdgfFdgBm72SUQ6BeyWjoY/ton0tEjH+OSH9iP4Dfh+7HM0I9f5eR0L/4w==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<!-- Your stuff: Third-party CSS libraries go here -->
|
||||
|
||||
<!-- This file stores project-specific CSS -->
|
||||
<link href="{% static 'css/project.css' %}" rel="stylesheet">
|
||||
{% endblock %}
|
||||
<!-- Le javascript
|
||||
================================================== -->
|
||||
{# Placed at the top of the document so pages load faster with defer #}
|
||||
{% block javascript %}
|
||||
<!-- Bootstrap JS -->
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/js/bootstrap.min.js" integrity="sha512-OvBgP9A2JBgiRad/mM36mkzXSXaJE9BEIENnVEmeZdITvwT09xnxLtT4twkCa8m/loMbPHsvPl0T8lRGVBwjlQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<!-- Your stuff: Third-party javascript libraries go here -->
|
||||
|
||||
<!-- place project specific Javascript in this file -->
|
||||
<script defer src="{% static 'js/project.js' %}"></script>
|
||||
|
||||
{% endblock javascript %}
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="mb-1">
|
||||
<nav class="navbar navbar-expand-md navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<button class="navbar-toggler navbar-toggler-right" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="{% url 'home' %}">akarpov</a>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<li class="nav-item active">
|
||||
<a class="nav-link" href="{% url 'home' %}">Home <span class="visually-hidden">(current)</span></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'about' %}">About</a>
|
||||
</li>
|
||||
{% if request.user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
{# URL provided by django-allauth/account/urls.py #}
|
||||
<a class="nav-link" href="{% url 'users:detail' request.user.username %}">{% translate "My Profile" %}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
{# URL provided by django-allauth/account/urls.py #}
|
||||
<a class="nav-link" href="{% url 'account_logout' %}">{% translate "Sign Out" %}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
{% if ACCOUNT_ALLOW_REGISTRATION %}
|
||||
<li class="nav-item">
|
||||
{# URL provided by django-allauth/account/urls.py #}
|
||||
<a id="sign-up-link" class="nav-link" href="{% url 'account_signup' %}">{% translate "Sign Up" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
{# URL provided by django-allauth/account/urls.py #}
|
||||
<a id="log-in-link" class="nav-link" href="{% url 'account_login' %}">{% translate "Sign In" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-dismissible {% if message.tags %}alert-{{ message.tags }}{% endif %}">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% block content %}
|
||||
<p>Use this document as a way to quick start any new project.</p>
|
||||
{% endblock content %}
|
||||
|
||||
</div> <!-- /container -->
|
||||
|
||||
{% block modal %}{% endblock modal %}
|
||||
|
||||
{% block inline_javascript %}
|
||||
{% comment %}
|
||||
Script tags with only code, no src (defer by default). To run
|
||||
with a "defer" so that you run inline code:
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', () => {/* Run whatever you want */});
|
||||
</script>
|
||||
{% endcomment %}
|
||||
{% endblock inline_javascript %}
|
||||
</body>
|
||||
</html>
|
4
akarpov/templates/email_template.html
Normal file
4
akarpov/templates/email_template.html
Normal file
|
@ -0,0 +1,4 @@
|
|||
{% autoescape off %}
|
||||
Hey there,
|
||||
Here is your token: {{ uid }}:{{ token }}
|
||||
{% endautoescape %}
|
1
akarpov/templates/pages/about.html
Normal file
1
akarpov/templates/pages/about.html
Normal file
|
@ -0,0 +1 @@
|
|||
{% extends "base.html" %}
|
1
akarpov/templates/pages/home.html
Normal file
1
akarpov/templates/pages/home.html
Normal file
|
@ -0,0 +1 @@
|
|||
{% extends "base.html" %}
|
34
akarpov/templates/users/user_detail.html
Normal file
34
akarpov/templates/users/user_detail.html
Normal file
|
@ -0,0 +1,34 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}User: {{ object.username }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
|
||||
<h2>{{ object.username }}</h2>
|
||||
{% if object.name %}
|
||||
<p>{{ object.name }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if object == request.user %}
|
||||
<!-- Action buttons -->
|
||||
<div class="row">
|
||||
|
||||
<div class="col-sm-12">
|
||||
<a class="btn btn-primary" href="{% url 'users:update' %}" role="button">My Info</a>
|
||||
<a class="btn btn-primary" href="{% url 'account_email' %}" role="button">E-Mail</a>
|
||||
<!-- Your Stuff: Custom user template urls -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- End Action buttons -->
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock content %}
|
17
akarpov/templates/users/user_form.html
Normal file
17
akarpov/templates/users/user_form.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
{% extends "base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}{{ user.username }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ user.username }}</h1>
|
||||
<form class="form-horizontal" method="post" action="{% url 'users:update' %}">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<div class="control-group">
|
||||
<div class="controls">
|
||||
<button type="submit" class="btn btn-primary">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
0
akarpov/users/__init__.py
Normal file
0
akarpov/users/__init__.py
Normal file
16
akarpov/users/adapters.py
Normal file
16
akarpov/users/adapters.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
from typing import Any
|
||||
|
||||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest
|
||||
|
||||
|
||||
class AccountAdapter(DefaultAccountAdapter):
|
||||
def is_open_for_signup(self, request: HttpRequest):
|
||||
return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True)
|
||||
|
||||
|
||||
class SocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
def is_open_for_signup(self, request: HttpRequest, sociallogin: Any):
|
||||
return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True)
|
34
akarpov/users/admin.py
Normal file
34
akarpov/users/admin.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
from django.contrib import admin
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .forms import UserAdminChangeForm, UserAdminCreationForm
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(auth_admin.UserAdmin):
|
||||
form = UserAdminChangeForm
|
||||
add_form = UserAdminCreationForm
|
||||
fieldsets = (
|
||||
(None, {"fields": ("username", "password")}),
|
||||
(_("Personal info"), {"fields": ("email",)}),
|
||||
(_("Images"), {"fields": ("image", "image_cropped")}),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
"fields": (
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"groups",
|
||||
"user_permissions",
|
||||
),
|
||||
},
|
||||
),
|
||||
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
list_display = ["username", "is_superuser"]
|
||||
search_fields = ["username", "email"]
|
59
akarpov/users/api/serializers.py
Normal file
59
akarpov/users/api/serializers.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from akarpov.users.models import User
|
||||
from akarpov.users.services.email_validation import activate
|
||||
|
||||
|
||||
class UserRegisterSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("id", "username", "email", "password")
|
||||
extra_kwargs = {
|
||||
"password": {"write_only": True},
|
||||
"email": {"required": True},
|
||||
"id": {"read_only": True},
|
||||
}
|
||||
|
||||
|
||||
class UserEmailVerification(serializers.Serializer):
|
||||
token = serializers.CharField(max_length=255)
|
||||
|
||||
def validate_token(self, token):
|
||||
activate(token.split(":")[0], token.split(":")[1])
|
||||
return token
|
||||
|
||||
|
||||
class UserPublicInfoSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
view_name="user_retrieve_username_api", lookup_field="username"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("id", "username", "url", "image_cropped")
|
||||
|
||||
|
||||
class UserFullPublicInfoSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("id", "username", "email", "is_superuser", "about", "image")
|
||||
|
||||
|
||||
class UserFullSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = (
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"about",
|
||||
"image",
|
||||
)
|
||||
extra_kwargs = {
|
||||
"id": {"read_only": True},
|
||||
"email": {"read_only": True},
|
||||
"is_staff": {"read_only": True},
|
||||
"is_superuser": {"read_only": True},
|
||||
}
|
129
akarpov/users/api/views.py
Normal file
129
akarpov/users/api/views.py
Normal file
|
@ -0,0 +1,129 @@
|
|||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import generics, permissions, status, views
|
||||
from rest_framework.response import Response
|
||||
from rest_framework_simplejwt.settings import api_settings
|
||||
from rest_framework_simplejwt.views import TokenViewBase
|
||||
|
||||
from akarpov.common.api import SmallResultsSetPagination
|
||||
from akarpov.users.api.serializers import (
|
||||
UserEmailVerification,
|
||||
UserFullPublicInfoSerializer,
|
||||
UserFullSerializer,
|
||||
UserPublicInfoSerializer,
|
||||
UserRegisterSerializer,
|
||||
)
|
||||
from akarpov.users.models import User
|
||||
|
||||
|
||||
class UserRegisterViewSet(generics.CreateAPIView):
|
||||
"""Creates new user and sends verification email"""
|
||||
|
||||
serializer_class = UserRegisterSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
operation_id="auth_user_register",
|
||||
)
|
||||
def post(self, request, *args, **kwargs):
|
||||
return self.create(request, *args, **kwargs)
|
||||
|
||||
|
||||
class TokenObtainPairView(TokenViewBase):
|
||||
"""
|
||||
Takes a set of user credentials and returns an access and refresh JSON web
|
||||
token pair to prove the authentication of those credentials.
|
||||
"""
|
||||
|
||||
_serializer_class = api_settings.TOKEN_OBTAIN_SERIALIZER
|
||||
|
||||
@extend_schema(operation_id="auth_token_obtain")
|
||||
def post(self, request, *args, **kwargs):
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
|
||||
class TokenRefreshView(TokenViewBase):
|
||||
"""
|
||||
Takes a refresh type JSON web token and returns an access type JSON web
|
||||
token if the refresh token is valid.
|
||||
"""
|
||||
|
||||
_serializer_class = api_settings.TOKEN_REFRESH_SERIALIZER
|
||||
|
||||
@extend_schema(operation_id="auth_token_refresh")
|
||||
def post(self, request, *args, **kwargs):
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
|
||||
class UserEmailValidationViewSet(views.APIView):
|
||||
"""Receives token from email and activates user"""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
serializer_class = UserEmailVerification
|
||||
|
||||
@extend_schema(
|
||||
operation_id="auth_user_email_prove",
|
||||
request=UserEmailVerification(),
|
||||
responses={200: "", 400: {"message": "Incorrect token"}},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = UserEmailVerification(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class UserListViewSet(generics.ListAPIView):
|
||||
serializer_class = UserPublicInfoSerializer
|
||||
pagination_class = SmallResultsSetPagination
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
queryset = User.objects.get_queryset().filter(is_active=True).order_by("id")
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return self.list(request, *args, **kwargs)
|
||||
|
||||
|
||||
class UserRetrieveViewSet(generics.RetrieveAPIView):
|
||||
"""Returns user's instance on username"""
|
||||
|
||||
serializer_class = UserFullPublicInfoSerializer
|
||||
lookup_field = "username"
|
||||
|
||||
queryset = User.objects.all()
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
operation_id="user_username_lookup",
|
||||
)
|
||||
def get(self, request, *args, **kwargs):
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
class UserRetrieveIdViewSet(UserRetrieveViewSet):
|
||||
"""Returns user's instance on user's id"""
|
||||
|
||||
lookup_field = "pk"
|
||||
|
||||
@extend_schema(
|
||||
operation_id="user_id_lookup",
|
||||
)
|
||||
def get(self, request, *args, **kwargs):
|
||||
return self.retrieve(request, *args, **kwargs)
|
||||
|
||||
|
||||
class UserRetireUpdateSelfViewSet(generics.RetrieveUpdateDestroyAPIView):
|
||||
serializer_class = UserFullSerializer
|
||||
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return self.retrieve(request, *args, **kwargs)
|
||||
|
||||
def put(self, request, *args, **kwargs):
|
||||
return self.update(request, *args, **kwargs)
|
||||
|
||||
def patch(self, request, *args, **kwargs):
|
||||
return self.partial_update(request, *args, **kwargs)
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
return self.destroy(request, *args, **kwargs)
|
13
akarpov/users/apps.py
Normal file
13
akarpov/users/apps.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
name = "akarpov.users"
|
||||
verbose_name = _("Users")
|
||||
|
||||
def ready(self):
|
||||
try:
|
||||
import akarpov.users.signals # noqa F401
|
||||
except ImportError:
|
||||
pass
|
8
akarpov/users/context_processors.py
Normal file
8
akarpov/users/context_processors.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from django.conf import settings
|
||||
|
||||
|
||||
def allauth_settings(request):
|
||||
"""Expose some settings from django-allauth in templates."""
|
||||
return {
|
||||
"ACCOUNT_ALLOW_REGISTRATION": settings.ACCOUNT_ALLOW_REGISTRATION,
|
||||
}
|
42
akarpov/users/forms.py
Normal file
42
akarpov/users/forms.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
from allauth.account.forms import SignupForm
|
||||
from allauth.socialaccount.forms import SignupForm as SocialSignupForm
|
||||
from django.contrib.auth import forms as admin_forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserAdminChangeForm(admin_forms.UserChangeForm):
|
||||
class Meta(admin_forms.UserChangeForm.Meta):
|
||||
model = User
|
||||
|
||||
|
||||
class UserAdminCreationForm(admin_forms.UserCreationForm):
|
||||
"""
|
||||
Form for User Creation in the Admin Area.
|
||||
To change user signup, see UserSignupForm and UserSocialSignupForm.
|
||||
"""
|
||||
|
||||
class Meta(admin_forms.UserCreationForm.Meta):
|
||||
model = User
|
||||
|
||||
error_messages = {
|
||||
"username": {"unique": _("This username has already been taken.")}
|
||||
}
|
||||
|
||||
|
||||
class UserSignupForm(SignupForm):
|
||||
"""
|
||||
Form that will be rendered on a user sign up section/screen.
|
||||
Default fields will be added automatically.
|
||||
Check UserSocialSignupForm for accounts created from social.
|
||||
"""
|
||||
|
||||
|
||||
class UserSocialSignupForm(SocialSignupForm):
|
||||
"""
|
||||
Renders the form when user has signed up using social accounts.
|
||||
Default fields will be added automatically.
|
||||
See UserSignupForm otherwise.
|
||||
"""
|
10
akarpov/users/middleware.py
Normal file
10
akarpov/users/middleware.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
from django.utils.deprecation import MiddlewareMixin
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
|
||||
class EmailVerificationMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request):
|
||||
if request.user.is_authenticated:
|
||||
if not request.user.is_verified:
|
||||
raise AuthenticationFailed("Email is not verified")
|
||||
return None
|
124
akarpov/users/migrations/0001_initial.py
Normal file
124
akarpov/users/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,124 @@
|
|||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="User",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, verbose_name="email address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
blank=True, max_length=255, verbose_name="Name of User"
|
||||
),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.Group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.Permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "user",
|
||||
"verbose_name_plural": "users",
|
||||
"abstract": False,
|
||||
},
|
||||
managers=[
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1,37 @@
|
|||
# Generated by Django 4.0.8 on 2022-11-16 20:00
|
||||
|
||||
import akarpov.utils.files
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='user',
|
||||
options={'ordering': ['-id']},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='name',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='about',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='image',
|
||||
field=models.ImageField(blank=True, upload_to=akarpov.utils.files.user_file_upload_mixin),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='image_cropped',
|
||||
field=models.ImageField(blank=True, upload_to='cropped/'),
|
||||
),
|
||||
]
|
0
akarpov/users/migrations/__init__.py
Normal file
0
akarpov/users/migrations/__init__.py
Normal file
22
akarpov/users/models.py
Normal file
22
akarpov/users/models.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
from akarpov.utils.files import user_file_upload_mixin
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""Base user model, to store all user info"""
|
||||
|
||||
first_name = None
|
||||
last_name = None
|
||||
|
||||
image = models.ImageField(upload_to=user_file_upload_mixin, blank=True)
|
||||
image_cropped = models.ImageField(upload_to="cropped/", blank=True)
|
||||
|
||||
about = models.TextField(blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
class Meta:
|
||||
ordering = ["-id"]
|
0
akarpov/users/services/__init__.py
Normal file
0
akarpov/users/services/__init__.py
Normal file
22
akarpov/users/services/email_validation.py
Normal file
22
akarpov/users/services/email_validation.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.http import urlsafe_base64_decode
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from akarpov.utils.generators import TokenGenerator
|
||||
|
||||
|
||||
def activate(uidb64, token):
|
||||
User = get_user_model()
|
||||
account_activation_token = TokenGenerator()
|
||||
try:
|
||||
uid = force_str(urlsafe_base64_decode(uidb64))
|
||||
user = User.objects.get(pk=uid)
|
||||
except (TypeError, ValueError, OverflowError, User.DoesNotExist):
|
||||
user = None
|
||||
if user is not None and account_activation_token.check_token(user, token):
|
||||
user.is_active = True
|
||||
user.save()
|
||||
return user
|
||||
else:
|
||||
raise ValidationError("Incorrect token")
|
42
akarpov/users/signals.py
Normal file
42
akarpov/users/signals.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
from django.core.files import File
|
||||
from django.core.mail import send_mail
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.utils.http import urlsafe_base64_encode
|
||||
|
||||
from akarpov.users.models import User
|
||||
from akarpov.utils.files import crop_image
|
||||
from akarpov.utils.generators import TokenGenerator
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
instance.is_active = False
|
||||
instance.set_password(instance.password)
|
||||
instance.save()
|
||||
account_activation_token = TokenGenerator()
|
||||
|
||||
mail_subject = "Account activation at akarpov.ru."
|
||||
message = render_to_string(
|
||||
"email_template.html",
|
||||
{
|
||||
"user": instance,
|
||||
"uid": urlsafe_base64_encode(force_bytes(instance.pk)),
|
||||
"token": account_activation_token.make_token(instance),
|
||||
},
|
||||
)
|
||||
send_mail(mail_subject, message, "main@akarpov.ru", [instance.email])
|
||||
|
||||
if instance.image:
|
||||
instance.image_cropped.save(
|
||||
instance.image.path.split(".")[0].split("/")[-1] + ".png",
|
||||
File(crop_image(instance.image.path, cut_to=(250, 250))),
|
||||
save=False,
|
||||
)
|
||||
|
||||
post_save.disconnect(create_user, sender=sender)
|
||||
instance.save(update_fields=["image_cropped"])
|
||||
post_save.connect(create_user, sender=User)
|
11
akarpov/users/tasks.py
Normal file
11
akarpov/users/tasks.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
|
||||
from config import celery_app
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@celery_app.task()
|
||||
def get_users_count():
|
||||
"""A pointless Celery task to demonstrate usage."""
|
||||
return User.objects.count()
|
10
akarpov/users/urls.py
Normal file
10
akarpov/users/urls.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
from django.urls import path
|
||||
|
||||
from akarpov.users.views import user_detail_view, user_redirect_view, user_update_view
|
||||
|
||||
app_name = "users"
|
||||
urlpatterns = [
|
||||
path("~redirect/", view=user_redirect_view, name="redirect"),
|
||||
path("~update/", view=user_update_view, name="update"),
|
||||
path("<str:username>/", view=user_detail_view, name="detail"),
|
||||
]
|
48
akarpov/users/views.py
Normal file
48
akarpov/users/views.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, RedirectView, UpdateView
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserDetailView(LoginRequiredMixin, DetailView):
|
||||
|
||||
model = User
|
||||
slug_field = "username"
|
||||
slug_url_kwarg = "username"
|
||||
|
||||
|
||||
user_detail_view = UserDetailView.as_view()
|
||||
|
||||
|
||||
class UserUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
|
||||
model = User
|
||||
fields = ["name"]
|
||||
success_message = _("Information successfully updated")
|
||||
|
||||
def get_success_url(self):
|
||||
assert (
|
||||
self.request.user.is_authenticated
|
||||
) # for mypy to know that the user is authenticated
|
||||
return self.request.user.get_absolute_url()
|
||||
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
|
||||
user_update_view = UserUpdateView.as_view()
|
||||
|
||||
|
||||
class UserRedirectView(LoginRequiredMixin, RedirectView):
|
||||
|
||||
permanent = False
|
||||
|
||||
def get_redirect_url(self):
|
||||
return reverse("users:detail", kwargs={"username": self.request.user.username})
|
||||
|
||||
|
||||
user_redirect_view = UserRedirectView.as_view()
|
0
akarpov/utils/__init__.py
Normal file
0
akarpov/utils/__init__.py
Normal file
32
akarpov/utils/files.py
Normal file
32
akarpov/utils/files.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
import os
|
||||
from io import BytesIO
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def crop_image(image_path: str, cut_to=(500, 500)):
|
||||
"""Makes image's thumbnail bt given parameters. By default, crops to 500x500"""
|
||||
img = Image.open(image_path)
|
||||
blob = BytesIO()
|
||||
|
||||
try:
|
||||
img.thumbnail(cut_to, Image.ANTIALIAS)
|
||||
except OSError:
|
||||
print("Can't crop")
|
||||
|
||||
img.save(blob, "PNG")
|
||||
return blob
|
||||
|
||||
|
||||
def user_file_upload_mixin(instance, filename):
|
||||
"""stores user uploaded files at their folder in media dir"""
|
||||
username = ""
|
||||
if isinstance(instance, get_user_model()):
|
||||
username = instance.username
|
||||
elif hasattr(instance, "user"):
|
||||
username = instance.user.username
|
||||
elif hasattr(instance, "creator"):
|
||||
username = instance.creator.username
|
||||
|
||||
return os.path.join(f"uploads/{username}/", filename)
|
16
akarpov/utils/generators.py
Normal file
16
akarpov/utils/generators.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
import random
|
||||
import string
|
||||
|
||||
from django.contrib.auth.tokens import PasswordResetTokenGenerator
|
||||
|
||||
|
||||
class TokenGenerator(PasswordResetTokenGenerator):
|
||||
"""token processor for user"""
|
||||
|
||||
def _make_hash_value(self, user, timestamp):
|
||||
return str(str(user.pk) + str(timestamp) + str(user.is_active))
|
||||
|
||||
|
||||
def generate_charset(length: int) -> str:
|
||||
"""Generate a random string of characters of a given length."""
|
||||
return "".join(random.choice(string.ascii_letters) for _ in range(length))
|
81
compose/local/django/Dockerfile
Normal file
81
compose/local/django/Dockerfile
Normal file
|
@ -0,0 +1,81 @@
|
|||
ARG PYTHON_VERSION=3.10-slim-bullseye
|
||||
|
||||
# define an alias for the specfic python version used in this file.
|
||||
FROM python:${PYTHON_VERSION} as python
|
||||
|
||||
# Python build stage
|
||||
FROM python as python-build-stage
|
||||
|
||||
ARG BUILD_ENVIRONMENT=local
|
||||
|
||||
# Install apt packages
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
# dependencies for building Python packages
|
||||
build-essential \
|
||||
# psycopg2 dependencies
|
||||
libpq-dev
|
||||
|
||||
# Requirements are installed here to ensure they will be cached.
|
||||
COPY ./requirements .
|
||||
|
||||
# Create Python Dependency and Sub-Dependency Wheels.
|
||||
RUN pip wheel --wheel-dir /usr/src/app/wheels \
|
||||
-r ${BUILD_ENVIRONMENT}.txt
|
||||
|
||||
|
||||
# Python 'run' stage
|
||||
FROM python as python-run-stage
|
||||
|
||||
ARG BUILD_ENVIRONMENT=local
|
||||
ARG APP_HOME=/app
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV BUILD_ENV ${BUILD_ENVIRONMENT}
|
||||
|
||||
WORKDIR ${APP_HOME}
|
||||
|
||||
# Install required system dependencies
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
# psycopg2 dependencies
|
||||
libpq-dev \
|
||||
# Translations dependencies
|
||||
gettext \
|
||||
# cleaning up unused files
|
||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# All absolute dir copies ignore workdir instruction. All relative dir copies are wrt to the workdir instruction
|
||||
# copy python dependency wheels from python-build-stage
|
||||
COPY --from=python-build-stage /usr/src/app/wheels /wheels/
|
||||
|
||||
# use wheels to install python dependencies
|
||||
RUN pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* \
|
||||
&& rm -rf /wheels/
|
||||
|
||||
COPY ./compose/production/django/entrypoint /entrypoint
|
||||
RUN sed -i 's/\r$//g' /entrypoint
|
||||
RUN chmod +x /entrypoint
|
||||
|
||||
COPY ./compose/local/django/start /start
|
||||
RUN sed -i 's/\r$//g' /start
|
||||
RUN chmod +x /start
|
||||
|
||||
|
||||
COPY ./compose/local/django/celery/worker/start /start-celeryworker
|
||||
RUN sed -i 's/\r$//g' /start-celeryworker
|
||||
RUN chmod +x /start-celeryworker
|
||||
|
||||
COPY ./compose/local/django/celery/beat/start /start-celerybeat
|
||||
RUN sed -i 's/\r$//g' /start-celerybeat
|
||||
RUN chmod +x /start-celerybeat
|
||||
|
||||
COPY ./compose/local/django/celery/flower/start /start-flower
|
||||
RUN sed -i 's/\r$//g' /start-flower
|
||||
RUN chmod +x /start-flower
|
||||
|
||||
|
||||
# copy application code to WORKDIR
|
||||
COPY . ${APP_HOME}
|
||||
|
||||
ENTRYPOINT ["/entrypoint"]
|
8
compose/local/django/celery/beat/start
Normal file
8
compose/local/django/celery/beat/start
Normal file
|
@ -0,0 +1,8 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o nounset
|
||||
|
||||
|
||||
rm -f './celerybeat.pid'
|
||||
celery -A config.celery_app beat -l INFO
|
11
compose/local/django/celery/flower/start
Normal file
11
compose/local/django/celery/flower/start
Normal file
|
@ -0,0 +1,11 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o nounset
|
||||
|
||||
|
||||
celery \
|
||||
-A config.celery_app \
|
||||
-b "${CELERY_BROKER_URL}" \
|
||||
flower \
|
||||
--basic_auth="${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}"
|
7
compose/local/django/celery/worker/start
Normal file
7
compose/local/django/celery/worker/start
Normal file
|
@ -0,0 +1,7 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o nounset
|
||||
|
||||
|
||||
watchfiles celery.__main__.main --args '-A config.celery_app worker -l INFO'
|
9
compose/local/django/start
Normal file
9
compose/local/django/start
Normal file
|
@ -0,0 +1,9 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py runserver_plus 0.0.0.0:8000
|
64
compose/local/docs/Dockerfile
Normal file
64
compose/local/docs/Dockerfile
Normal file
|
@ -0,0 +1,64 @@
|
|||
ARG PYTHON_VERSION=3.10-slim-bullseye
|
||||
|
||||
# define an alias for the specfic python version used in this file.
|
||||
FROM python:${PYTHON_VERSION} as python
|
||||
|
||||
|
||||
# Python build stage
|
||||
FROM python as python-build-stage
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
# dependencies for building Python packages
|
||||
build-essential \
|
||||
# psycopg2 dependencies
|
||||
libpq-dev \
|
||||
# cleaning up unused files
|
||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Requirements are installed here to ensure they will be cached.
|
||||
COPY ./requirements /requirements
|
||||
|
||||
# create python dependency wheels
|
||||
RUN pip wheel --no-cache-dir --wheel-dir /usr/src/app/wheels \
|
||||
-r /requirements/local.txt -r /requirements/production.txt \
|
||||
&& rm -rf /requirements
|
||||
|
||||
|
||||
# Python 'run' stage
|
||||
FROM python as python-run-stage
|
||||
|
||||
ARG BUILD_ENVIRONMENT
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
# To run the Makefile
|
||||
make \
|
||||
# psycopg2 dependencies
|
||||
libpq-dev \
|
||||
# Translations dependencies
|
||||
gettext \
|
||||
# Uncomment below lines to enable Sphinx output to latex and pdf
|
||||
# texlive-latex-recommended \
|
||||
# texlive-fonts-recommended \
|
||||
# texlive-latex-extra \
|
||||
# latexmk \
|
||||
# cleaning up unused files
|
||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# copy python dependency wheels from python-build-stage
|
||||
COPY --from=python-build-stage /usr/src/app/wheels /wheels
|
||||
|
||||
# use wheels to install python dependencies
|
||||
RUN pip install --no-cache /wheels/* \
|
||||
&& rm -rf /wheels
|
||||
|
||||
COPY ./compose/local/docs/start /start-docs
|
||||
RUN sed -i 's/\r$//g' /start-docs
|
||||
RUN chmod +x /start-docs
|
||||
|
||||
WORKDIR /docs
|
7
compose/local/docs/start
Normal file
7
compose/local/docs/start
Normal file
|
@ -0,0 +1,7 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
make livehtml
|
94
compose/production/django/Dockerfile
Normal file
94
compose/production/django/Dockerfile
Normal file
|
@ -0,0 +1,94 @@
|
|||
ARG PYTHON_VERSION=3.10-slim-bullseye
|
||||
|
||||
|
||||
|
||||
# define an alias for the specfic python version used in this file.
|
||||
FROM python:${PYTHON_VERSION} as python
|
||||
|
||||
# Python build stage
|
||||
FROM python as python-build-stage
|
||||
|
||||
ARG BUILD_ENVIRONMENT=production
|
||||
|
||||
# Install apt packages
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
# dependencies for building Python packages
|
||||
build-essential \
|
||||
# psycopg2 dependencies
|
||||
libpq-dev
|
||||
|
||||
# Requirements are installed here to ensure they will be cached.
|
||||
COPY ./requirements .
|
||||
|
||||
# Create Python Dependency and Sub-Dependency Wheels.
|
||||
RUN pip wheel --wheel-dir /usr/src/app/wheels \
|
||||
-r ${BUILD_ENVIRONMENT}.txt
|
||||
|
||||
|
||||
# Python 'run' stage
|
||||
FROM python as python-run-stage
|
||||
|
||||
ARG BUILD_ENVIRONMENT=production
|
||||
ARG APP_HOME=/app
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV BUILD_ENV ${BUILD_ENVIRONMENT}
|
||||
|
||||
WORKDIR ${APP_HOME}
|
||||
|
||||
RUN addgroup --system django \
|
||||
&& adduser --system --ingroup django django
|
||||
|
||||
|
||||
# Install required system dependencies
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
# psycopg2 dependencies
|
||||
libpq-dev \
|
||||
# Translations dependencies
|
||||
gettext \
|
||||
# cleaning up unused files
|
||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# All absolute dir copies ignore workdir instruction. All relative dir copies are wrt to the workdir instruction
|
||||
# copy python dependency wheels from python-build-stage
|
||||
COPY --from=python-build-stage /usr/src/app/wheels /wheels/
|
||||
|
||||
# use wheels to install python dependencies
|
||||
RUN pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* \
|
||||
&& rm -rf /wheels/
|
||||
|
||||
|
||||
COPY --chown=django:django ./compose/production/django/entrypoint /entrypoint
|
||||
RUN sed -i 's/\r$//g' /entrypoint
|
||||
RUN chmod +x /entrypoint
|
||||
|
||||
|
||||
COPY --chown=django:django ./compose/production/django/start /start
|
||||
RUN sed -i 's/\r$//g' /start
|
||||
RUN chmod +x /start
|
||||
COPY --chown=django:django ./compose/production/django/celery/worker/start /start-celeryworker
|
||||
RUN sed -i 's/\r$//g' /start-celeryworker
|
||||
RUN chmod +x /start-celeryworker
|
||||
|
||||
|
||||
COPY --chown=django:django ./compose/production/django/celery/beat/start /start-celerybeat
|
||||
RUN sed -i 's/\r$//g' /start-celerybeat
|
||||
RUN chmod +x /start-celerybeat
|
||||
|
||||
|
||||
COPY ./compose/production/django/celery/flower/start /start-flower
|
||||
RUN sed -i 's/\r$//g' /start-flower
|
||||
RUN chmod +x /start-flower
|
||||
|
||||
|
||||
# copy application code to WORKDIR
|
||||
COPY --chown=django:django . ${APP_HOME}
|
||||
|
||||
# make django owner of the WORKDIR directory as well.
|
||||
RUN chown django:django ${APP_HOME}
|
||||
|
||||
USER django
|
||||
|
||||
ENTRYPOINT ["/entrypoint"]
|
8
compose/production/django/celery/beat/start
Normal file
8
compose/production/django/celery/beat/start
Normal file
|
@ -0,0 +1,8 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
|
||||
exec celery -A config.celery_app beat -l INFO
|
11
compose/production/django/celery/flower/start
Normal file
11
compose/production/django/celery/flower/start
Normal file
|
@ -0,0 +1,11 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o nounset
|
||||
|
||||
|
||||
exec celery \
|
||||
-A config.celery_app \
|
||||
-b "${CELERY_BROKER_URL}" \
|
||||
flower \
|
||||
--basic_auth="${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}"
|
8
compose/production/django/celery/worker/start
Normal file
8
compose/production/django/celery/worker/start
Normal file
|
@ -0,0 +1,8 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
|
||||
exec celery -A config.celery_app worker -l INFO
|
49
compose/production/django/entrypoint
Normal file
49
compose/production/django/entrypoint
Normal file
|
@ -0,0 +1,49 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
|
||||
|
||||
# N.B. If only .env files supported variable expansion...
|
||||
export CELERY_BROKER_URL="${REDIS_URL}"
|
||||
|
||||
|
||||
if [ -z "${POSTGRES_USER}" ]; then
|
||||
base_postgres_image_default_user='postgres'
|
||||
export POSTGRES_USER="${base_postgres_image_default_user}"
|
||||
fi
|
||||
export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
|
||||
|
||||
python << END
|
||||
import sys
|
||||
import time
|
||||
|
||||
import psycopg2
|
||||
|
||||
suggest_unrecoverable_after = 30
|
||||
start = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
psycopg2.connect(
|
||||
dbname="${POSTGRES_DB}",
|
||||
user="${POSTGRES_USER}",
|
||||
password="${POSTGRES_PASSWORD}",
|
||||
host="${POSTGRES_HOST}",
|
||||
port="${POSTGRES_PORT}",
|
||||
)
|
||||
break
|
||||
except psycopg2.OperationalError as error:
|
||||
sys.stderr.write("Waiting for PostgreSQL to become available...\n")
|
||||
|
||||
if time.time() - start > suggest_unrecoverable_after:
|
||||
sys.stderr.write(" This is taking longer than expected. The following exception may be indicative of an unrecoverable error: '{}'\n".format(error))
|
||||
|
||||
time.sleep(1)
|
||||
END
|
||||
|
||||
>&2 echo 'PostgreSQL is available'
|
||||
|
||||
exec "$@"
|
10
compose/production/django/start
Normal file
10
compose/production/django/start
Normal file
|
@ -0,0 +1,10 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
|
||||
python /app/manage.py collectstatic --noinput
|
||||
|
||||
/usr/local/bin/gunicorn config.wsgi --bind 0.0.0.0:5000 --chdir=/app
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user