inited django

This commit is contained in:
Alexander Karpov 2022-11-18 10:19:20 +03:00
commit 3f806b13a2
150 changed files with 4848 additions and 0 deletions

10
.dockerignore Normal file
View 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
View 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
View File

@ -0,0 +1,3 @@
DATABASE_URL=
CELERY_BROKER_URL=
USE_DOCKER=

14
.envs/.local/.django Normal file
View 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
View 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
View File

@ -0,0 +1 @@
* text=auto

79
.github/dependabot.yml vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
sanspie

9
LICENSE Normal file
View 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
View 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
View File

View File

View 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
View 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
View 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

View 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')},
},
),
]

View File

84
akarpov/blog/models.py Normal file
View 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"]

View File

View 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
View 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"])

View File

19
akarpov/common/api.py Normal file
View 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
View 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()

View 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
"""

View 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
"""

View 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())],
)
]

View 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",
),
)
]

View File

@ -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)]

View File

@ -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",
},
),
]

View 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
"""

View 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;
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@ -0,0 +1 @@
/* Project specific Javascript goes here. */

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@ -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 %}

View 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 %}

View 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" %} &raquo;</button>
</form>
{% endblock %}

View 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 %}

View 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 %}

View 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
View 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>

View File

@ -0,0 +1,4 @@
{% autoescape off %}
Hey there,
Here is your token: {{ uid }}:{{ token }}
{% endautoescape %}

View File

@ -0,0 +1 @@
{% extends "base.html" %}

View File

@ -0,0 +1 @@
{% extends "base.html" %}

View 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 %}

View 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 %}

View File

16
akarpov/users/adapters.py Normal file
View 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
View 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"]

View 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
View 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
View 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

View 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
View 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.
"""

View 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

View 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()),
],
),
]

View File

@ -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/'),
),
]

View File

22
akarpov/users/models.py Normal file
View 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"]

View File

View 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
View 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
View 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
View 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
View 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()

View File

32
akarpov/utils/files.py Normal file
View 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)

View 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))

View 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"]

View 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

View 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}"

View 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'

View 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

View 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
View File

@ -0,0 +1,7 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
make livehtml

View 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"]

View 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

View 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}"

View 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

View 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 "$@"

View 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