gogsadmin пре 1 година
комит
4e436e1cc0
67 измењених фајлова са 1592 додато и 0 уклоњено
  1. 0 0
      api/__init__.py
  2. 3 0
      api/admin.py
  3. 6 0
      api/apps.py
  4. 0 0
      api/migrations/__init__.py
  5. 3 0
      api/models.py
  6. 22 0
      api/serializers.py
  7. 3 0
      api/tests.py
  8. 9 0
      api/urls.py
  9. 40 0
      api/views.py
  10. BIN
      board.db.sqlite3
  11. 0 0
      board/__init__.py
  12. 16 0
      board/asgi.py
  13. 151 0
      board/settings.py
  14. 34 0
      board/urls.py
  15. 16 0
      board/wsgi.py
  16. 0 0
      main/__init__.py
  17. 86 0
      main/admin.py
  18. 17 0
      main/apps.py
  19. 86 0
      main/forms.py
  20. 22 0
      main/middlewares.py
  21. 46 0
      main/migrations/0001_initial.py
  22. 60 0
      main/migrations/0002_rubric_alter_advuser_send_messages_subrubric_and_more.py
  23. 53 0
      main/migrations/0003_ad_alter_rubric_name_additionalimage.py
  24. 30 0
      main/migrations/0004_comment.py
  25. 0 0
      main/migrations/__init__.py
  26. 117 0
      main/models.py
  27. BIN
      main/static/main/bg.jpg
  28. BIN
      main/static/main/empty.jpg
  29. 12 0
      main/static/main/style.css
  30. 11 0
      main/templates/email/activation_letter_body.txt
  31. 1 0
      main/templates/email/activation_letter_subject.txt
  32. 10 0
      main/templates/email/new_comment_letter_body.txt
  33. 1 0
      main/templates/email/new_comment_letter_subject.txt
  34. 60 0
      main/templates/layout/basic.html
  35. 8 0
      main/templates/main/about.html
  36. 9 0
      main/templates/main/activation_done.html
  37. 9 0
      main/templates/main/bad_signature.html
  38. 44 0
      main/templates/main/by_rubric.html
  39. 14 0
      main/templates/main/change_user_info.html
  40. 13 0
      main/templates/main/delete_user.html
  41. 50 0
      main/templates/main/detail.html
  42. 31 0
      main/templates/main/index.html
  43. 19 0
      main/templates/main/login.html
  44. 8 0
      main/templates/main/logout.html
  45. 14 0
      main/templates/main/password_change.html
  46. 44 0
      main/templates/main/profile.html
  47. 14 0
      main/templates/main/profile_ad_add.html
  48. 15 0
      main/templates/main/profile_ad_change.html
  49. 25 0
      main/templates/main/profile_ad_delete.html
  50. 41 0
      main/templates/main/profile_ad_detail.html
  51. 9 0
      main/templates/main/register_done.html
  52. 14 0
      main/templates/main/register_user.html
  53. 9 0
      main/templates/main/user_is_activated.html
  54. 3 0
      main/tests.py
  55. 26 0
      main/urls.py
  56. 29 0
      main/utilities.py
  57. 207 0
      main/views.py
  58. 22 0
      manage.py
  59. BIN
      media/1673036695.980743.jpg
  60. BIN
      media/1673036695.992017.jpg
  61. BIN
      media/1673036695.994043.jpg
  62. BIN
      media/1673037135.579345.jpg
  63. BIN
      media/1673037135.583253.jpg
  64. BIN
      media/1673208732.510287.jpeg
  65. BIN
      media/thumbnails/1673036695.980743.jpg.96x96_q85_crop-scale.jpg
  66. BIN
      media/thumbnails/1673037135.579345.jpg.96x96_q85_crop-scale.jpg
  67. BIN
      media/thumbnails/1673208732.510287.jpeg.96x96_q85_crop-scale.jpg

+ 0 - 0
api/__init__.py


+ 3 - 0
api/admin.py

@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.

+ 6 - 0
api/apps.py

@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ApiConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'api'

+ 0 - 0
api/migrations/__init__.py


+ 3 - 0
api/models.py

@@ -0,0 +1,3 @@
+from django.db import models
+
+# Create your models here.

+ 22 - 0
api/serializers.py

@@ -0,0 +1,22 @@
+from rest_framework import serializers
+
+from main.models import Ad, Comment
+
+
+# Сериализатор, формирующий список объявлений
+class AdSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Ad
+        fields = ('id', 'title', 'content', 'price', 'created_at')
+
+# Сериализатор, выдающий сведения об объявлении
+class AdDetailSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Ad
+        fields = ('id', 'title', 'content', 'price', 'created_at', 'contacts', 'image')
+
+# Сериализатор, выдающий список комментариев и добавляющий новый комментарий
+class CommentSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Comment
+        fields = ('ad', 'author', 'content', 'created_at')

+ 3 - 0
api/tests.py

@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.

+ 9 - 0
api/urls.py

@@ -0,0 +1,9 @@
+from django.urls import path
+
+from .views import ads, AdDetailView, comments
+
+urlpatterns = [
+    path('ads/<int:pk>/comments/', comments),
+    path('ads/<int:pk>/', AdDetailView.as_view()),
+    path('ads/', ads),
+]

+ 40 - 0
api/views.py

@@ -0,0 +1,40 @@
+from django.shortcuts import render
+from rest_framework.response import Response
+from rest_framework.decorators import api_view, permission_classes
+from rest_framework.generics import RetrieveAPIView
+from rest_framework.status import HTTP_201_CREATED, HTTP_400_BAD_REQUEST
+from rest_framework.permissions import IsAuthenticatedOrReadOnly
+
+
+from main.models import Ad, Comment
+from .serializers import AdSerializer, AdDetailSerializer, CommentSerializer
+
+# Выдаем список объявлений
+@api_view(['GET'])
+def ads(request):
+    if request.method == 'GET':
+        ads = Ad.objects.filter(is_active=True)[:10]
+        serializer = AdSerializer(ads, many=True)
+        return Response(serializer.data)
+
+# Выдаем сведения о выбранном объявлении
+class AdDetailView(RetrieveAPIView):
+    queryset = Ad.objects.filter(is_active=True)
+    serializer_class = AdDetailSerializer
+
+# Добавлять комментарий разрешено только зарегистрированным пользователям
+# Просматривать комментарии разрешено всем
+@api_view(['GET', 'POST'])
+@permission_classes((IsAuthenticatedOrReadOnly,))
+def comments(request, pk):
+    if request.method == 'POST':
+        serializer = CommentSerializer(data=request.data)
+        if serializer.is_valid():
+            serializer.save()
+            return Response(serializer.data, status=HTTP_201_CREATED)
+        else:
+            return Response(serializer.errors, status=HTTP_400_BAD_REQUEST)
+    else:
+        comments = Comment.objects.filter(is_active=True, ad=pk)
+        serializer = CommentSerializer(comments, many=True)
+        return Response(serializer.data)


+ 0 - 0
board/__init__.py


+ 16 - 0
board/asgi.py

@@ -0,0 +1,16 @@
+"""
+ASGI config for board project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'board.settings')
+
+application = get_asgi_application()

+ 151 - 0
board/settings.py

@@ -0,0 +1,151 @@
+"""
+Django settings for board project.
+
+Generated by 'django-admin startproject' using Django 4.1.4.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/4.1/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/4.1/ref/settings/
+"""
+
+from pathlib import Path
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = 'django-insecure-zqg+!d+16wwcyv$dv&vvox4d!*8jkazh0conc866uiw08cz(^g'
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = ['board.adminkin.com', '95.31.13.58', 'server2014.ru']
+
+
+# Application definition
+
+INSTALLED_APPS = [
+    'django.contrib.admin',
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.messages',
+    'django.contrib.staticfiles',
+    'main.apps.MainConfig',
+    'bootstrap4',
+    'django_cleanup',
+    'easy_thumbnails',
+    'captcha',
+    'rest_framework',
+    'corsheaders',
+    'api.apps.ApiConfig',
+]
+
+MIDDLEWARE = [
+    'django.middleware.security.SecurityMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'corsheaders.middleware.CorsMiddleware',
+    'django.middleware.common.CommonMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+    'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = 'board.urls'
+
+TEMPLATES = [
+    {
+        'BACKEND': 'django.template.backends.django.DjangoTemplates',
+        'DIRS': [],
+        'APP_DIRS': True,
+        'OPTIONS': {
+            'context_processors': [
+                'django.template.context_processors.debug',
+                'django.template.context_processors.request',
+                'django.contrib.auth.context_processors.auth',
+                'django.contrib.messages.context_processors.messages',
+                'main.middlewares.board_context_processor',
+            ],
+        },
+    },
+]
+
+WSGI_APPLICATION = 'board.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/4.1/ref/settings/#databases
+
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': BASE_DIR / 'board.db.sqlite3',
+    }
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+    {
+        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+    },
+]
+AUTH_USER_MODEL = 'main.AdvUser'
+
+# Internationalization
+# https://docs.djangoproject.com/en/4.1/topics/i18n/
+
+LANGUAGE_CODE = 'ru'
+TIME_ZONE = 'Europe/Moscow'
+USE_I18N = True
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/4.1/howto/static-files/
+
+import os
+STATIC_URL = 'static/'
+STATIC_ROOT = os.path.join(BASE_DIR, 'static')
+MEDIA_URL = 'media/'
+MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
+THUMBNAIL_ALIASES = {
+    '': {
+        'default': {
+            'size': (96, 96),
+            'crop': 'scale',
+        },
+    },
+}
+THUMBNAIL_BASEDIR = 'thumbnails' # Имя вложенной папки для хранения миниатюр
+
+# Default primary key field type
+# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
+
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+
+DEFAULT_FROM_EMAIL = 'no-reply@server2014.ru'
+EMAIL_HOST = 'localhost'
+EMAIL_PORT = 25
+
+# Разрешаем доступ к веб-службе с любого домена
+CORS_ORIGIN_ALLOW_ALL = True
+CORS_URLS_REGEX = r'^/api/.*$'

+ 34 - 0
board/urls.py

@@ -0,0 +1,34 @@
+"""board URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+    https://docs.djangoproject.com/en/4.1/topics/http/urls/
+Examples:
+Function views
+    1. Add an import:  from my_app import views
+    2. Add a URL to urlpatterns:  path('', views.home, name='home')
+Class-based views
+    1. Add an import:  from other_app.views import Home
+    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
+Including another URLconf
+    1. Import the include() function: from django.urls import include, path
+    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
+"""
+from django.contrib import admin
+from django.urls import path, include
+from django.conf import settings
+from django.contrib.staticfiles.views import serve
+from django.views.decorators.cache import never_cache
+from django.conf.urls.static import static
+
+urlpatterns = [
+    path('admin/', admin.site.urls),
+    path('captcha/', include('captcha.urls')),
+    path('api/', include('api.urls')),
+    path('', include('main.urls')),
+]
+
+if settings.DEBUG:
+    urlpatterns.append(path('static/<path:path>', never_cache(serve)))
+    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+
+

+ 16 - 0
board/wsgi.py

@@ -0,0 +1,16 @@
+"""
+WSGI config for board project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'board.settings')
+
+application = get_wsgi_application()

+ 0 - 0
main/__init__.py


+ 86 - 0
main/admin.py

@@ -0,0 +1,86 @@
+from django.contrib import admin
+import datetime
+
+from .utilities import send_activation_notification
+from .models import AdvUser, SuperRubric, SubRubric, Ad, AdditionalImage, Comment
+from .forms import SubRubricForm
+
+def send_activation_notifications(modeladmin, request, queryset):
+    for rec in queryset:
+        if not rec.is_activated:
+            send_activation_notification(rec)
+    modeladmin.message_user(request, 'Письма с требованиями отправлены')
+send_activation_notifications.short_description = 'Отправка писем с требованиями активации'
+
+class NonactivatedFilter(admin.SimpleListFilter):
+    title = 'Прошли активацию?'
+    parameter_name = 'actstate'
+
+    def lookups (self, request, model_admin):
+        return(('activated', 'Прошли'),
+               ('threedays', 'Не прошли более 3 дней'),
+               ('week', 'Не прошли более недели'),
+               )
+
+    def queryset (self, request, queryset):
+        val = self.value()
+        if val == 'activated':
+            return queryset.filter(is_active=True, is_activated=True)
+        elif val == 'threedays':
+            d = datetime.date.today() - datetime.timedelta(days=3)
+            return queryset.filter(is_active=False, is_activated=False, date_joined__date__lt=d)
+        elif val == 'week':
+            d = datetime.date.today() - datetime.timedelta(weeks=1)
+            return queryset.filter(is_active=False, is_activated=False, date_joined__date__lt=d)
+
+class AdvUserAdmin(admin.ModelAdmin):
+    list_display = ('__str__', 'is_activated', 'date_joined')
+    search_fields = ('username', 'email', 'first_name', 'last_name')
+    list_filter = (NonactivatedFilter, )
+    fields = (('username', 'email'), 
+              ('first_name', 'last_name'),
+              ('send_messages', 'is_active', 'is_activated'),
+              ('is_staff', 'is_superuser'),
+              'groups',
+              'user_permissions',
+              ('last_login', 'date_joined'))
+    readonly_fields = ('last_login', 'date_joined')
+    actions = (send_activation_notifications, )
+
+admin.site.register(AdvUser, AdvUserAdmin)
+
+class SubRubricInline(admin.TabularInline):
+    model = SubRubric
+
+class SuperRubricAdmin(admin.ModelAdmin):
+    # При добавлении новой Главной рубрики можно сразу же заполнить ее Подрубрики с помощью втроенного редактора.
+    exclude = ('super_rubric',) # Исключили необязательное поле для Главных рубрик
+    inlines = (SubRubricInline,)
+
+admin.site.register(SuperRubric, SuperRubricAdmin)
+
+class SubRubricAdmin(admin.ModelAdmin):
+    form = SubRubricForm
+
+admin.site.register(SubRubric, SubRubricAdmin)
+
+class AdditionalImageInline(admin.TabularInline):
+    model = AdditionalImage
+
+class AdAdmin(admin.ModelAdmin):
+    list_display = ('rubric', 'title', 'content', 'author', 'created_at')
+    fields = ('rubric', 'author', 'title', 'content', 'price', 'contacts', 'image', 'is_active')
+    inlines = (AdditionalImageInline, )
+
+admin.site.register(Ad, AdAdmin)
+
+class CommentAdmin(admin.ModelAdmin):
+    list_display = ('author', 'content', 'created_at', 'is_active')
+    list_display_links = ('author', 'content')
+    list_filter = ('is_active',)
+    search_fields = ('author', 'content', )
+    date_hierarchy = 'created_at'
+    fields = ('author', 'content', 'is_active', 'created_at')
+    readonly_fields = ('created_at', )
+
+admin.site.register(Comment, CommentAdmin)

+ 17 - 0
main/apps.py

@@ -0,0 +1,17 @@
+from django.apps import AppConfig
+from django.dispatch import Signal
+
+from .utilities import send_activation_notification
+
+user_registered = Signal('instance')
+
+def user_registered_dispatcher(sender, **kwargs):
+    send_activation_notification(kwargs['instance'])
+
+user_registered.connect(user_registered_dispatcher)
+
+class MainConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'main'
+    verbose_name = 'Доска объявлений'
+

+ 86 - 0
main/forms.py

@@ -0,0 +1,86 @@
+from django import forms
+from django.contrib.auth import password_validation
+from django.core.exceptions import ValidationError
+from django.forms import inlineformset_factory
+from captcha.fields import CaptchaField
+
+from .models import AdvUser, SuperRubric, SubRubric, Ad, AdditionalImage, Comment
+from .apps import user_registered
+
+class ChangeUserInfoForm(forms.ModelForm):
+    # Полное объявление поля email, т.к. хотим сделать поле email обязательным для заполнения1
+    email = forms.EmailField(required=True, label='Адрес электронной почты')
+    
+    class Meta:
+        model = AdvUser
+        fields = ('username', 'email', 'first_name', 'last_name', 'send_messages')
+
+class RegisterUserForm(forms.ModelForm):
+    email = forms.EmailField(required=True, label='Адрес электронной почты')
+    password1 = forms.CharField(required=True, label='Пароль', widget=forms.PasswordInput, help_text=password_validation.password_validators_help_text_html())
+    password2 = forms.CharField(required=True, label='Пароль (повторно)', widget=forms.PasswordInput, help_text='Введите тот же самый пароль еще раз для проверки')
+
+    def clean_password1(self):
+        password1 = self.cleaned_data['password1']
+        if password1:
+            password_validation.validate_password(password1)
+        return password1
+
+    def clean(self):
+        super().clean()
+        password1 = self.cleaned_data.get('password1')
+        password2 = self.cleaned_data.get('password2')
+        if password1 and password2 and password1 != password2:
+            errors = {'password2': ValidationError('Введенные пароли не совпадают', code='password_mismatch')}
+            raise ValidationError(errors)
+            
+    def save(self, commit=True):
+        user = super().save(commit=False)
+        user.set_password(self.cleaned_data['password1'])
+        user.is_active = False
+        user.is_activated = False
+        if commit:
+            user.save()
+        user_registered.send(RegisterUserForm, instance=user)
+        return user
+    
+    class Meta:
+        model = AdvUser
+        fields = ('username', 'email', 'password1', 'password2', 'first_name', 'last_name', 'send_messages')
+
+class SubRubricForm(forms.ModelForm):
+    # Для Подрубрик поле super_rubric обязательно для заполнения:
+    super_rubric = forms.ModelChoiceField(required=True, queryset=SuperRubric.objects.all(), empty_label=None, label='Главная рубрика')
+
+    class Meta:
+        model = SubRubric
+        fields = '__all__'
+
+# Форма поиска для главной страницы
+class SearchForm(forms.Form):
+    keyword = forms.CharField(required=False, max_length=40, label='')
+
+class AdForm(forms.ModelForm):
+    class Meta:
+        model = Ad
+        fields = '__all__'
+        widgets = {'author': forms.HiddenInput}
+
+AIFormSet = inlineformset_factory(Ad, AdditionalImage, fields='__all__')
+
+# Форма комментариев для зарегистрированного пользователя
+class UserCommentForm(forms.ModelForm):
+    class Meta:
+        model = Comment
+        exclude = ('is_active', )
+        widgets = {'ad': forms.HiddenInput}
+
+# Форма комментариев для гостей сайта
+class GuestCommentForm(forms.ModelForm):
+    captcha = CaptchaField(label='Введите текст с картинки', error_messages={'invalid': 'Неправильный текст'})
+    
+    class Meta:
+        model = Comment
+        exclude = ('is_active', )
+        widgets = {'ad': forms.HiddenInput}
+

+ 22 - 0
main/middlewares.py

@@ -0,0 +1,22 @@
+# Обработчик контекста
+from .models import SubRubric
+
+def board_context_processor(request):
+    context = {}
+    context['rubrics'] = SubRubric.objects.all() # Список Подрубрик помещается в переменную rubrics контекста шаблона
+    # Реализация корректного возврата
+    context['keyword'] = ''
+    context['all'] = ''
+    if 'keyword' in request.GET:
+        keyword = request.GET['keyword']
+        if keyword:
+            context['keyword'] = '?keyword=' + keyword
+            context['all'] = context['keyword']
+    if 'page' in request.GET:
+        page = request.GET['page']
+        if page != '1':
+            if context['all']:
+                context['all'] += '&page=' + page
+            else:
+                context['all'] = '?page=' + page
+    return context

+ 46 - 0
main/migrations/0001_initial.py

@@ -0,0 +1,46 @@
+# Generated by Django 4.1.4 on 2022-12-16 19:11
+
+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='AdvUser',
+            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')),
+                ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
+                ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
+                ('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')),
+                ('is_activated', models.BooleanField(db_index=True, default=True, verbose_name='Прошел активацию?')),
+                ('send_messages', models.BooleanField(default=True, verbose_name='Слать оповещение о новых коментариях?')),
+                ('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()),
+            ],
+        ),
+    ]

+ 60 - 0
main/migrations/0002_rubric_alter_advuser_send_messages_subrubric_and_more.py

@@ -0,0 +1,60 @@
+# Generated by Django 4.1.4 on 2023-01-05 20:35
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('main', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Rubric',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Название')),
+                ('order', models.SmallIntegerField(db_index=True, default=0, verbose_name='Порядок следования')),
+            ],
+        ),
+        migrations.AlterField(
+            model_name='advuser',
+            name='send_messages',
+            field=models.BooleanField(default=True, verbose_name='Оповещение на email о новых комментариях?'),
+        ),
+        migrations.CreateModel(
+            name='SubRubric',
+            fields=[
+            ],
+            options={
+                'verbose_name': 'Подрубрика',
+                'verbose_name_plural': 'Подрубрики',
+                'ordering': ('super_rubric__order', 'super_rubric__name', 'order', 'name'),
+                'proxy': True,
+                'indexes': [],
+                'constraints': [],
+            },
+            bases=('main.rubric',),
+        ),
+        migrations.CreateModel(
+            name='SuperRubric',
+            fields=[
+            ],
+            options={
+                'verbose_name': 'Главная рубрика',
+                'verbose_name_plural': 'Главные рубрики',
+                'ordering': ('order', 'name'),
+                'proxy': True,
+                'indexes': [],
+                'constraints': [],
+            },
+            bases=('main.rubric',),
+        ),
+        migrations.AddField(
+            model_name='rubric',
+            name='super_rubric',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='main.superrubric', verbose_name='Главная рубрика'),
+        ),
+    ]

+ 53 - 0
main/migrations/0003_ad_alter_rubric_name_additionalimage.py

@@ -0,0 +1,53 @@
+# Generated by Django 4.1.4 on 2023-01-06 14:21
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import main.utilities
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('main', '0002_rubric_alter_advuser_send_messages_subrubric_and_more'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Ad',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('title', models.CharField(max_length=40, verbose_name='Товар')),
+                ('content', models.TextField(verbose_name='Описание')),
+                ('price', models.FloatField(default=0, verbose_name='Цена')),
+                ('contacts', models.TextField(verbose_name='Контакты')),
+                ('image', models.ImageField(blank=True, upload_to=main.utilities.get_timestamp_path, verbose_name='Изображение')),
+                ('is_active', models.BooleanField(db_index=True, default=True, verbose_name='Выводить в списке объявлений?')),
+                ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Опубликовано')),
+                ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Автор объявления')),
+                ('rubric', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='main.subrubric', verbose_name='Рубрика')),
+            ],
+            options={
+                'verbose_name': 'Объявление',
+                'verbose_name_plural': 'Объявления',
+                'ordering': ['-created_at'],
+            },
+        ),
+        migrations.AlterField(
+            model_name='rubric',
+            name='name',
+            field=models.CharField(db_index=True, max_length=30, unique=True, verbose_name='Название'),
+        ),
+        migrations.CreateModel(
+            name='AdditionalImage',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('image', models.ImageField(upload_to=main.utilities.get_timestamp_path, verbose_name='Изображение')),
+                ('ad', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.ad', verbose_name='Объявление')),
+            ],
+            options={
+                'verbose_name': 'Дополнительное изображение',
+                'verbose_name_plural': 'Дополнительные изображения',
+            },
+        ),
+    ]

+ 30 - 0
main/migrations/0004_comment.py

@@ -0,0 +1,30 @@
+# Generated by Django 4.1.4 on 2023-01-09 09:01
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('main', '0003_ad_alter_rubric_name_additionalimage'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Comment',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('author', models.CharField(max_length=30, verbose_name='Автор')),
+                ('content', models.TextField(verbose_name='Комментарий')),
+                ('is_active', models.BooleanField(db_index=True, default=True, verbose_name='Выводить на экран?')),
+                ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Опубликован')),
+                ('ad', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.ad', verbose_name='Объявление')),
+            ],
+            options={
+                'verbose_name': 'Комментарий',
+                'verbose_name_plural': 'Комментарии',
+                'ordering': ['created_at'],
+            },
+        ),
+    ]

+ 0 - 0
main/migrations/__init__.py


+ 117 - 0
main/models.py

@@ -0,0 +1,117 @@
+from django.db import models
+from django.contrib.auth.models import AbstractUser
+from django.db.models.signals import post_save
+
+from .utilities import get_timestamp_path, send_new_comment_notification
+
+class AdvUser(AbstractUser):
+    is_activated = models.BooleanField(default=True, db_index=True, verbose_name='Прошел активацию?')
+    send_messages = models.BooleanField(default=True, verbose_name='Оповещение на email о новых комментариях?')
+    
+    # При удалении пользователя удаляем и его объявления
+    def delete(self, *args, **kwargs):
+        for ad in self.ad_set.all():
+            ad.delete()
+        super().delete(*args, **kwargs)
+
+    class Meta(AbstractUser.Meta):
+        pass
+
+class Rubric(models.Model):
+    # Базовая модель, в которой хранятся и главные рубрики, и подрубрики
+    name = models.CharField(max_length=30, db_index=True, unique=True, verbose_name='Название')
+    order = models.SmallIntegerField(default=0, db_index=True, verbose_name='Порядок следования')
+    super_rubric = models.ForeignKey('SuperRubric', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Главная рубрика')
+
+class SuperRubricManager(models.Manager):
+    # Обработка только главных рубрик
+    def get_queryset(self):
+        return super().get_queryset().filter(super_rubric__isnull=True)
+
+class SuperRubric(Rubric):
+    objects = SuperRubricManager()
+    
+    def __str__(self):
+        # Метод генерит строковое название Главной рубрики
+        return self.name
+
+    class Meta:
+        proxy = True
+        ordering = ('order', 'name')
+        verbose_name = 'Главная рубрика'
+        verbose_name_plural = 'Главные рубрики'
+
+class SubRubricManager(models.Manager):
+    def get_queryset(self):
+        return super().get_queryset().filter(super_rubric__isnull=False)
+
+class SubRubric(Rubric):
+    objects = SubRubricManager()
+    
+    def __str__(self):
+        return '%s - %s' % (self.super_rubric.name, self.name)
+
+    class Meta:
+        proxy = True
+        ordering = ('super_rubric__order', 'super_rubric__name', 'order', 'name')
+        verbose_name = 'Подрубрика'
+        verbose_name_plural = 'Подрубрики'
+
+# Класс самих объявлений
+class Ad(models.Model):
+    rubric = models.ForeignKey(SubRubric, on_delete=models.PROTECT, verbose_name='Рубрика') # Запрет каскадного удаления
+    title = models.CharField(max_length=40, verbose_name='Товар')
+    content = models.TextField(verbose_name='Описание')
+    price = models.FloatField(default=0, verbose_name='Цена')
+    contacts = models.TextField(verbose_name='Контакты')
+    image = models.ImageField(blank=True, upload_to=get_timestamp_path, verbose_name='Изображение')
+    # разрешаем каскадное удаление. Т.е. при удалении объявления будут уничтожены все относящиеся к нему Дополнительные Изображения.
+    # Это действие выполнит не Django, а СУБД. Т.е. физического удаления с диска файлов Изображений не произойдет.
+    author = models.ForeignKey(AdvUser, on_delete=models.CASCADE, verbose_name='Автор объявления')
+    is_active = models.BooleanField(default=True, db_index=True, verbose_name='Выводить в списке объявлений?')
+    created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Опубликовано')
+
+    # Перед удалением текущей записи мы перебираем и вызовом метода delete() удаляем все связанные Дополнительные Изображения.
+    # При вызове метода delete() возникает сигнал post_delete, обрабатываемый приложением django_cleanup, которое удалит файлы Изображений физически с диска.
+    def delete(self, *args, **kwargs):
+        for ai in self.additionalimage_set.all():
+            ai.delete()
+        super().delete(*args, **kwargs)
+
+    class Meta:
+        verbose_name_plural = 'Объявления'
+        verbose_name = 'Объявление'
+        ordering = ['-created_at']
+
+# Класс Дополнительных изображений
+class AdditionalImage(models.Model):
+    # Объявление, к которому относится Изображение
+    ad = models.ForeignKey(Ad, on_delete=models.CASCADE, verbose_name='Объявление')
+    image = models.ImageField(upload_to=get_timestamp_path, verbose_name='Изображение')
+
+    class Meta:
+        verbose_name_plural = 'Дополнительные изображения'
+        verbose_name = 'Дополнительное изображение'
+
+#Класс Комментариев
+class Comment(models.Model):
+    ad = models.ForeignKey(Ad, on_delete=models.CASCADE, verbose_name='Объявление')
+    author = models.CharField(max_length=30, verbose_name='Автор')
+    content = models.TextField(verbose_name='Комментарий')
+    is_active = models.BooleanField(default=True, db_index=True, verbose_name='Выводить на экран?')
+    created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Опубликован')
+
+    class Meta:
+        verbose_name_plural = 'Комментарии'
+        verbose_name = 'Комментарий'
+        ordering = ['created_at'] # Сортировка по увеличению временной отметки, т.е. более старые комментарии будут располагаться в начале списка, а более новые - в конце.
+
+# Привяжем к сигналу post_save обработчик, вызывающий функцию send_new_comment_notification
+# после добавления комментария
+def post_save_dispatcher(sender, **kwargs):
+    author = kwargs['instance'].ad.author
+    if kwargs['created'] and author.send_messages:
+        send_new_comment_notification(kwargs['instance'])
+
+post_save.connect(post_save_dispatcher, sender=Comment)
+

BIN
main/static/main/bg.jpg


BIN
main/static/main/empty.jpg


+ 12 - 0
main/static/main/style.css

@@ -0,0 +1,12 @@
+header h1 {
+    background: url("bg.jpg") left / auto 100% no-repeat, url("bg.jpg") right / auto 100% no-repeat;
+}
+.root {
+    font-size: larger;
+}
+img.main-image {
+    width: 300px;
+}
+img.additional-image {
+    width: 180px;
+}

+ 11 - 0
main/templates/email/activation_letter_body.txt

@@ -0,0 +1,11 @@
+Уважаемый пользователь {{ user.username }}!
+
+Вы зарегистрировались на сайте "Доска объявлений".
+Вам необходимо выполнить активацию, чтобы подтвердить свою личность.
+Для этого пройдите, пожалуйста, по ссылке
+
+{{ host }}{% url 'main:register_activate' sign=sign %}
+
+До свидания!
+
+С уважением, администрация сайта "Доска объявлений".

+ 1 - 0
main/templates/email/activation_letter_subject.txt

@@ -0,0 +1 @@
+Активация пользователя {{ user.username }}

+ 10 - 0
main/templates/email/new_comment_letter_body.txt

@@ -0,0 +1,10 @@
+Уважаемый пользователь {{ user.username }}!
+
+К одному из Ваших объявлений оставлен новый комментарий.
+Чтобы прочитать его, пройдите, пожалуйста, по ссылке
+
+{{ host }}{% url 'main:profile_ad_detail' pk=comment.ad.pk %}
+
+До свидания!
+
+С уважением, администрация сайта "Доска объявлений".

+ 1 - 0
main/templates/email/new_comment_letter_subject.txt

@@ -0,0 +1 @@
+Новый комментарий оставлен под объявлением пользователя {{ author.username }}

+ 60 - 0
main/templates/layout/basic.html

@@ -0,0 +1,60 @@
+{% load bootstrap4 %}
+{% load static %}
+<!DOCTYPE html>
+<html>
+    <head>
+        <meta charset="UTF-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+        <title>{% block title %}Главная{% endblock %} - Доска объявлений</title>
+        <meta name="robots" content="noindex, nofollow"/>
+        {% bootstrap_css %}
+        <link rel="stylesheet" type="text/css" href="{% static 'main/style.css' %}">
+        {% bootstrap_javascript jquery='slim' %}
+    </head>
+    <body class="container-fluid">
+        <header class="mb-4">
+            <h1 class="display-1 text-center">Объявления</h1>
+        </header>
+        <div class="row">
+            <ul class="col nav justify-content-end border">
+                <li class="nav-item"><a class="nav-link" href="{% url 'main:register' %}">Регистрация</a></li>
+                {% if user.is_authenticated %}
+                <li class="nav-item dropdown">
+                    <a class="nav-link dropdown-toggle" data-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false">Личный кабинет</a>
+                    <div class="dropdown-menu dropdown-menu-right">
+                        <a class="dropdown-item" href="{% url 'main:profile' %}">Мои объявления</a>
+                        <a class="dropdown-item" href="{% url 'main:profile_change' %}">Изменить личные данные</a>
+                        <a class="dropdown-item" href="{% url 'main:password_change' %}">Изменить пароль</a>
+                        <div class="dropdown-divider"></div>
+                        <a class="dropdown-item" href="{% url 'main:logout' %}">Выйти</a>
+                        <div class="dropdown-divider"></div>
+                        <a class="dropdown-item" href="{% url 'main:profile_delete' %}">Удалить</a>
+                    </div>
+                </li>
+                {% else %}
+                <li class="nav-item"><a class="nav-link" href="{% url 'main:login' %}">Вход</a></li>
+                {% endif %}
+            </ul>
+        </div>
+        <div class="row">
+            <nav class="col-md-auto nav flex-column border">
+                <a class="nav-link root" href="{% url 'main:index' %}">Главная</a>
+                {% for rubric in rubrics %}
+                {% ifchanged rubric.super_rubric.pk %}
+                <span class="nav-link root font-weight-bold">{{ rubric.super_rubric.name }}</span>
+                {% endifchanged %}
+                <a class="nav-link" href="{% url 'main:by_rubric' pk=rubric.pk %}">{{ rubric.name }}</a>
+                {% endfor %}
+                <a class="nav-link root" href="{% url 'main:other' page='about' %}">О сайте</a>
+            </nav>
+            <section class="col border py-2">
+                {% bootstrap_messages %}
+                {% block content %}
+                {% endblock %}
+            </section>
+        </div>
+        <footer class="mt-3">
+            <p class="text-right font-italic">&copy; 2023</p>
+        </footer>
+    </body>
+</html>

+ 8 - 0
main/templates/main/about.html

@@ -0,0 +1,8 @@
+{% extends "layout/basic.html" %}
+
+{% block title %}О сайте{% endblock %}
+
+{% block content %}
+<h2>О сайте</h2>
+<p>Сайт для публикации бесплатных объявлений о продаже.</p>
+{% endblock %}

+ 9 - 0
main/templates/main/activation_done.html

@@ -0,0 +1,9 @@
+{% extends "layout/basic.html" %}
+
+{% block title %}Активация выполнена{% endblock %}
+
+{% block content %}
+<h2>Активация</h2>
+<p>Пользователь успешно активирован.</p>
+<p><a href="{% url 'main:login' %}">Войти на сайт</a></p>
+{% endblock %}

+ 9 - 0
main/templates/main/bad_signature.html

@@ -0,0 +1,9 @@
+{% extends "layout/basic.html" %}
+
+{% block title %}Ошибка при активации{% endblock %}
+
+{% block content %}
+<h2>Активация</h2>
+<p>Активация пользователя прошла неудачно.</p>
+<p><a href="{% url 'main:register' %}">Зарегистрироваться повторно</a></p>
+{% endblock %}

+ 44 - 0
main/templates/main/by_rubric.html

@@ -0,0 +1,44 @@
+{% extends "layout/basic.html" %}
+
+{% load thumbnail %}
+{% load static %}
+{% load bootstrap4 %}
+
+{% block title %}{{ rubric }}{% endblock %}
+
+{% block content %}
+<h2 class="mb-2">{{ rubric }}</h2>
+<div class="container-fluid mb-2">
+    <div class="row">
+        <div class="col">&nbsp;</div>
+        <form class="col-md-auto form-inline">
+            {% bootstrap_form form show_label=False %}
+            {% bootstrap_button content='Искать' button_type='submit' %}
+        </form>
+    </div>
+</div>
+{% if ads %}
+<ul class="list-unstyled">
+    {% for ad in ads %}
+    <li class="media my-5 p-3 border">
+        {% url 'main:detail' rubric_pk=rubric.pk pk=ad.pk as url %}
+        <a href="{{ url }}{{ all }}">
+        {% if ad.image %}
+        <img class="mr-3" src="{% thumbnail ad.image 'default' %}">
+        {% else %}
+        <img class="mr-3" src="{% static 'main/empty.jpg' %}">
+        {% endif %}
+        </a>
+        <div class="media-body">
+          <h3><a href="{{ url }}{{ all }}">
+          {{ ad.title }}</a></h3>
+          <div>{{ ad.content }}</div>
+          <p class="text-right font-weight-bold">{{ ad.price }} руб.</p>
+          <p class="text-right font-italic">{{ ad.created_at }}</p>
+        </div>
+    </li>
+    {% endfor %}
+</ul>
+{% bootstrap_pagination page url=keyword %}
+{% endif %}
+{% endblock %}

+ 14 - 0
main/templates/main/change_user_info.html

@@ -0,0 +1,14 @@
+{% extends "layout/basic.html" %}
+
+{% load bootstrap4 %}
+
+{% block title %}Правка личных данных{% endblock %}
+
+{% block content %}
+<h2>Правка личных данных пользователя {{ user.username }}</h2>
+<form method="post">
+    {% csrf_token %}
+    {% bootstrap_form form layout='horizontal' %}
+    {% buttons submit='Сохранить' %}{% endbuttons %}
+</form>
+{% endblock %}

+ 13 - 0
main/templates/main/delete_user.html

@@ -0,0 +1,13 @@
+{% extends "layout/basic.html" %}
+
+{% load bootstrap4 %}
+
+{% block title %}Удаление пользователя{% endblock %}
+
+{% block content %}
+<h2>Удаление пользователя {{ object.username }}</h2>
+<form method="post">
+    {% csrf_token %}
+    {% buttons submit='Удалить' %}{% endbuttons %}
+</form>
+{% endblock %}

+ 50 - 0
main/templates/main/detail.html

@@ -0,0 +1,50 @@
+{% extends "layout/basic.html" %}
+
+{% load bootstrap4 %}
+
+{% block title %}{{ ad.title }} - {{ ad.rubric.name }}{% endblock %}
+
+{% block content %}
+<div class="container-fluid mt-3">
+    <div class="row">
+        {% if ad.image %}
+        <div class="col-md-auto"><img src="{{ ad.image.url }}"
+        class="main-image"></div>
+        {% endif %}
+        <div class="col">
+            <h2>{{ ad.title }}</h2>
+            <p>{{ ad.content }}</p>
+            <p class="font-weight-bold">{{ ad.price }} руб.</p>
+            <p>{{ ad.contacts }}</p>
+            <p class="text-right font-italic">Добавлено {{ ad.created_at }}</p>
+        </div>
+    </div>
+</div>
+{% if ais %}
+<div class="d-flex justify-content-between flex-wrap mt-5">
+    {% for ai in ais %}
+    <div>
+        <img class="additional-image" src="{{ ai.image.url }}">
+    </div>
+    {% endfor %}
+</div>
+{% endif %}
+<p><a href="{% url 'main:by_rubric' pk=ad.rubric.pk %}{{ all }}">Назад</a></p>
+<h4 class="mt-5">Новый комментарий</h4>
+<form method="post">
+    {% csrf_token %}
+    {% bootstrap_form form layout='horizontal' %}
+    {% buttons submit='Добавить' %}{% endbuttons %}
+</form>
+{% if comments %}
+<div class="mt-5">
+    {% for comment in comments %}
+    <div class="my-2 p-2 border">
+	<h5>{{ comment.author }}</h5>
+	<p>{{ comment.content }}</p>
+	<p class="text-right font-italic">{{ comment.created_at }}</p>
+    </div>
+    {% endfor %}
+</div>
+{% endif %}
+{% endblock %}

+ 31 - 0
main/templates/main/index.html

@@ -0,0 +1,31 @@
+{% extends "layout/basic.html" %}
+
+{% load thumbnail %}
+{% load static %}
+
+{% block content %}
+<h2 class="mb-2">Последние 10 объявлений</h2>
+{% if ads %}
+<ul class="list-unstyled">
+    {% for ad in ads %}
+    <li class="media my-5 p-3 border">
+        {% url 'main:detail' rubric_pk=ad.rubric.pk pk=ad.pk as url %}
+        <a href="{{ url }}{{ all }}">
+        {% if ad.image %}
+        <img class="mr-3" src="{% thumbnail ad.image 'default' %}">
+        {% else %}
+        <img class="mr-3" src="{% static 'main/empty.jpg' %}">
+        {% endif %}
+        </a>
+        <div class="media-body">
+          <h3><a href="{{ url }}{{ all }}">
+          {{ ad.title }}</a></h3>
+          <div>{{ ad.content }}</div>
+          <p class="text-right font-weight-bold">{{ ad.price }} руб.</p>
+          <p class="text-right font-italic">{{ ad.created_at }}</p>
+        </div>
+    </li>
+    {% endfor %}
+</ul>
+{% endif %}
+{% endblock %}

+ 19 - 0
main/templates/main/login.html

@@ -0,0 +1,19 @@
+{% extends "layout/basic.html" %}
+
+{% load bootstrap4 %}
+
+{% block title %}Вход{% endblock %}
+
+{% block content %}
+<h2>Вход</h2>
+{% if user.is_authenticated %}
+<p>Вы уже выполнили вход.</p>
+{% else %}
+<form method="post">
+    {% csrf_token %}
+    {% bootstrap_form form layout='horizontal' %}
+    <input type="hidden" name="next" value="{{ next }}">
+    {% buttons submit='Войти' %}{% endbuttons %}
+</form>
+{% endif %}
+{% endblock %}

+ 8 - 0
main/templates/main/logout.html

@@ -0,0 +1,8 @@
+{% extends "layout/basic.html" %}
+
+{% block title %}Выход{% endblock %}
+
+{% block content %}
+<h2>Выход</h2>
+<p>Вы успешно вышли.</p>
+{% endblock %}

+ 14 - 0
main/templates/main/password_change.html

@@ -0,0 +1,14 @@
+{% extends "layout/basic.html" %}
+
+{% load bootstrap4 %}
+
+{% block title %}Смена пароля{% endblock %}
+
+{% block content %}
+<h2>Смена пароля пользователя {{ user.username }}</h2>
+<form method="post">
+    {% csrf_token %}
+    {% bootstrap_form form layout='horizontal' %}
+    {% buttons submit='Сменить пароль' %}{% endbuttons %}
+</form>
+{% endblock %}

+ 44 - 0
main/templates/main/profile.html

@@ -0,0 +1,44 @@
+{% extends "layout/basic.html" %}
+
+{% load thumbnail %}
+{% load static %}
+
+{% block title %}Личный кабинет пользователя{% endblock %}
+
+{% block content %}
+<h2>Личный кабинет пользователя {{ user.username }}</h2>
+{% if user.first_name and user.last_name %}
+<p>Здравствуйте, {{ user.first_name }} {{ user.last_name }}!</p>
+{% else %}
+<p>Здравствуйте!</p>
+{% endif %}
+<p><a href="{% url 'main:profile_ad_add' %}">Добавить объявление</a></p>
+{% if ads %}
+<h3>Ваши объявления</h3>
+<ul class="list-unstyled">
+    {% for ad in ads %}
+    <li class="media my-5 p-3 border">
+        {% url 'main:profile_ad_detail' pk=ad.pk as url %}
+        <a href="{{ url }}{{ all }}">
+        {% if ad.image %}
+        <img class="mr-3" src="{% thumbnail ad.image 'default' %}">
+        {% else %}
+        <img class="mr-3" src="{% static 'main/empty.jpg' %}">
+        {% endif %}
+        </a>
+        <div class="media-body">
+          <h3><a href="{{ url }}{{ all }}">
+          {{ ad.title }}</a></h3>
+          <div>{{ ad.content }}</div>
+          <p class="text-right font-weight-bold">{{ ad.price }} руб.</p>
+          <p class="text-right font-italic">{{ ad.created_at }}</p>
+          <p class="text-right mt-2">
+            <a href="{% url 'main:profile_ad_change' pk=ad.pk %}">Редактировать</a>
+            <a href="{% url 'main:profile_ad_delete' pk=ad.pk %}">Удалить</a>
+          </p>
+        </div>
+    </li>
+    {% endfor %}
+</ul>
+{% endif %}
+{% endblock %}

+ 14 - 0
main/templates/main/profile_ad_add.html

@@ -0,0 +1,14 @@
+{% extends "layout/basic.html" %}
+
+{% load bootstrap4 %}
+{% block title %}Добавление объявления - Личный кабинет пользователя{% endblock %}
+
+{% block content %}
+<h2>Добавление объявления</h2>
+<form method="post" enctype="multipart/form-data">
+    {% csrf_token %}
+    {% bootstrap_form form layout='horizontal' %}
+    {% bootstrap_formset formset layout='horizontal' %}
+    {% buttons submit='Добавить' %}{% endbuttons %}
+</form>
+{% endblock %}

+ 15 - 0
main/templates/main/profile_ad_change.html

@@ -0,0 +1,15 @@
+{% extends "layout/basic.html" %}
+
+{% load bootstrap4 %}
+
+{% block title %}Правка объявления - Личный кабинет пользователя{% endblock %}
+
+{% block content %}
+<h2>Правка объявления</h2>
+<form method="post" enctype="multipart/form-data">
+    {% csrf_token %}
+    {% bootstrap_form form layout='horizontal' %}
+    {% bootstrap_formset formset layout='horizontal' %}
+    {% buttons submit='Сохранить' %}{% endbuttons %}
+</form>
+{% endblock %}

+ 25 - 0
main/templates/main/profile_ad_delete.html

@@ -0,0 +1,25 @@
+{% extends "layout/basic.html" %}
+
+{% block title %}Удаление объявления - Личный кабинет пользователя{% endblock %}
+
+{% block content %}
+<h2>Удаление объявления</h2>
+<div class="container-fluid mt-3">
+    <div class="row">
+        {% if ad.image %}
+        <div class="col-md-auto"><img src="{{ ad.image.url }}" class="main-image"></div>
+        {% endif %}
+        <div class="col">
+            <h2>{{ ad.title }}</h2>
+            <p>{{ ad.content }}</p>
+            <p class="font-weight-bold">{{ ad.price }} руб.</p>
+            <p>{{ ad.contacts }}</p>
+            <p class="text-right font-italic">Добавлено {{ ad.created_at }}</p>
+        </div>
+    </div>
+</div>
+<form method="post">
+    {% csrf_token %}
+    <input type="submit" value="Удалить">
+</form>
+{% endblock %}

+ 41 - 0
main/templates/main/profile_ad_detail.html

@@ -0,0 +1,41 @@
+{% extends "layout/basic.html" %}
+
+{% block title %}{{ ad.title }} - Личный кабинет пользователя{% endblock %}
+
+{% block content %}
+<div class="container-fluid mt-3">
+    <div class="row">
+        {% if ad.image %}
+        <div class="col-md-auto"><img src="{{ ad.image.url }}" class="main-image"></div>
+        {% endif %}
+        <div class="col">
+            <h2>{{ ad.title }}</h2>
+            <p>{{ ad.content }}</p>
+            <p class="font-weight-bold">{{ ad.price }} руб.</p>
+            <p>{{ ad.contacts }}</p>
+            <p class="text-right font-italic">Добавлено {{ ad.created_at }}</p>
+        </div>
+    </div>
+</div>
+{% if ais %}
+<div class="d-flex justify-content-between flex-wrap mt-5">
+    {% for ai in ais %}
+    <div>
+        <img class="additional-image" src="{{ ai.image.url }}">
+    </div>
+    {% endfor %}
+</div>
+{% endif %}
+<p><a href="{% url 'main:profile' %}">Назад</a></p>
+{% if comments %}
+<div class="mt-5">
+    {% for comment in comments %}
+    <div class="my-2 p-2 border">
+	<h5>{{ comment.author }}</h5>
+	<p>{{ comment.content }}</p>
+	<p class="text-right font-italic">{{ comment.created_at }}</p>
+    </div>
+    {% endfor %}
+</div>
+{% endif %}
+{% endblock %}

+ 9 - 0
main/templates/main/register_done.html

@@ -0,0 +1,9 @@
+{% extends "layout/basic.html" %}
+
+{% block title %}Регистрация завершена{% endblock %}
+
+{% block content %}
+<h2>Регистрация</h2>
+<p>Регистрация пользователя завершена.</p>
+<p>На адрес электронной почты, указанный пользователем, выслано письмо для активации.</p>
+{% endblock %}

+ 14 - 0
main/templates/main/register_user.html

@@ -0,0 +1,14 @@
+{% extends "layout/basic.html" %}
+
+{% load bootstrap4 %}
+
+{% block title %}Регистрация{% endblock %}
+
+{% block content %}
+<h2>Регистрация нового пользователя</h2>
+<form method="post">
+    {% csrf_token %}
+    {% bootstrap_form form layout='horizontal' %}
+    {% buttons submit='Зарегистрироваться' %}{% endbuttons %}
+</form>
+{% endblock %}

+ 9 - 0
main/templates/main/user_is_activated.html

@@ -0,0 +1,9 @@
+{% extends "layout/basic.html" %}
+
+{% block title %}Пользователь уже активирован{% endblock %}
+
+{% block content %}
+<h2>Активация</h2>
+<p>Пользователь был активирован ранее.</p>
+<p><a href="{% url 'main:login' %}">Войти на сайт</a></p>
+{% endblock %}

+ 3 - 0
main/tests.py

@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.

+ 26 - 0
main/urls.py

@@ -0,0 +1,26 @@
+from django.urls import path
+
+from .views import index, other_page, BLoginView, profile, BLogoutView, ChangeUserInfoView, BPasswordChangeView, RegisterUserView, RegisterDoneView, user_activate, DeleteUserView
+from .views import by_rubric, detail, profile_ad_detail, profile_ad_add, profile_ad_change, profile_ad_delete
+
+app_name = 'main'
+urlpatterns = [
+    path('', index, name='index'),
+    path('<int:rubric_pk>/<int:pk>/', detail, name='detail'),
+    path('<int:pk>/', by_rubric, name='by_rubric'),
+    path('<str:page>/', other_page, name='other'),
+    path('accounts/register/activate/<str:sign>/', user_activate, name='register_activate'),
+    path('accounts/register/done/', RegisterDoneView.as_view(), name='register_done'),
+    path('accounts/register/', RegisterUserView.as_view(), name='register'),
+    path('accounts/login/', BLoginView.as_view(), name='login'),
+    path('accounts/profile/delete/', DeleteUserView.as_view(), name='profile_delete'),
+    path('accounts/profile/change/', ChangeUserInfoView.as_view(), name='profile_change'),
+    path('accounts/profile/change/<int:pk>/', profile_ad_change, name='profile_ad_change'),
+    path('accounts/profile/delete/<int:pk>/', profile_ad_delete, name='profile_ad_delete'),
+    path('account/profile/add/', profile_ad_add, name='profile_ad_add'),
+    path('accounts/profile/<int:pk>/', profile_ad_detail, name='profile_ad_detail'),
+    path('accounts/profile/', profile, name='profile'),
+    path('accounts/logout/', BLogoutView.as_view(), name='logout'),
+    path('accounts/password/change/', BPasswordChangeView.as_view(), name='password_change'),
+
+]

+ 29 - 0
main/utilities.py

@@ -0,0 +1,29 @@
+from django.template.loader import render_to_string
+from django.core.signing import Signer
+from datetime import datetime
+from os.path import splitext
+
+from board.settings import ALLOWED_HOSTS
+
+signer = Signer()
+
+def send_activation_notification(user):
+    if ALLOWED_HOSTS:
+        host = 'https://' + ALLOWED_HOSTS[0]
+        context = {'user': user, 'host': host, 'sign': signer.sign(user.username)}
+        subject = render_to_string('email/activation_letter_subject.txt', context)
+        body_text = render_to_string('email/activation_letter_body.txt', context)
+        user.email_user(subject, body_text)
+
+# Графические файлы, сохраняемые в папке image, будут иметь в качестве имен текущие временные отметки
+def get_timestamp_path(instance, filename):
+    return '%s%s' % (datetime.now().timestamp(), splitext(filename)[1])
+
+def send_new_comment_notification(comment):
+    if ALLOWED_HOSTS:
+        host = 'https://' + ALLOWED_HOSTS[0]
+        author = comment.ad.author
+        context = {'author': author, 'host': host, 'comment': comment}
+        subject = render_to_string('email/new_comment_letter_subject.txt', context)
+        body_text = render_to_string('email/new_comment_letter_body.txt', context)
+        author.email_user(subject, body_text)

+ 207 - 0
main/views.py

@@ -0,0 +1,207 @@
+from django.shortcuts import render, get_object_or_404, redirect
+from django.http import HttpResponse, Http404
+from django.template import TemplateDoesNotExist
+from django.template.loader import get_template
+from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth import logout
+from django.contrib import messages
+from django.contrib.messages.views import SuccessMessageMixin
+from django.views.generic.edit import UpdateView, CreateView, DeleteView
+from django.views.generic.base import TemplateView
+from django.urls import reverse_lazy
+from django.core.signing import BadSignature
+from django.core.paginator import Paginator
+from django.db.models import Q
+
+from .models import AdvUser, SubRubric, Ad, Comment
+from .forms import ChangeUserInfoForm, RegisterUserForm, SearchForm, AdForm, AIFormSet, UserCommentForm, GuestCommentForm
+from .utilities import signer
+
+def index(request):
+    # Вывод последних 10 объявлений
+    ads = Ad.objects.filter(is_active=True)[:10]
+    context = {'ads': ads}
+    return render(request, 'main/index.html', context)
+
+def other_page(request, page):
+    try:
+        template = get_template('main/' + page + '.html')
+    except TemplateDoesNotExist:
+        raise Http404
+    return HttpResponse(template.render(request=request))
+
+class BLoginView(LoginView):
+    template_name = 'main/login.html'
+
+@login_required
+def profile(request):
+    ads = Ad.objects.filter(author=request.user.pk)
+    context = {'ads': ads}
+    return render(request, 'main/profile.html', context)
+
+class BLogoutView(LoginRequiredMixin, LogoutView):
+    template_name = 'main/logout.html'
+
+class ChangeUserInfoView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
+    model = AdvUser
+    template_name = 'main/change_user_info.html'
+    form_class = ChangeUserInfoForm
+    success_url = reverse_lazy('main:profile')
+    success_message = 'Данные пользователя изменены'
+    
+    def setup(self, request, *args, **kwargs):
+        self.user_id = request.user.pk
+        return super().setup(request, *args, **kwargs)
+    def get_object(self, queryset=None):
+        if not queryset:
+            queryset = self.get_queryset()
+        return get_object_or_404(queryset, pk=self.user_id)
+
+class BPasswordChangeView(SuccessMessageMixin, LoginRequiredMixin, PasswordChangeView):
+    template_name = 'main/password_change.html'
+    success_url = reverse_lazy('main:profile')
+    success_message = 'Пароль пользователя изменен'
+    
+class RegisterUserView(CreateView):
+    model = AdvUser
+    template_name = 'main/register_user.html'
+    form_class = RegisterUserForm
+    success_url = reverse_lazy('main:register_done')
+
+class RegisterDoneView(TemplateView):
+    template_name = 'main/register_done.html'
+
+def user_activate(request, sign):
+    try:
+        username = signer.unsign(sign)
+    except BadSignature:
+        return render(request, 'main/bad_signature.html')
+    user = get_object_or_404(AdvUser, username=username)
+    if user.is_activated:
+        template = 'main/user_is_activated.html'
+    else:
+        template = 'main/activation_done.html'
+        user.is_active = True
+        user.is_activated = True
+        user.save()
+    return render(request, template)
+
+class DeleteUserView(LoginRequiredMixin, DeleteView):
+    model = AdvUser
+    template_name = 'main/delete_user.html'
+    success_url = reverse_lazy('main:index')
+    
+    def setup(self, request, *args, **kwargs): # Сохраняем ключ текущего пользователя
+        self.user_id = request.user.pk
+        return super().setup(request, *args, **kwargs)
+
+    def post(self, request, *args, **kwargs): # Делаем выход для текущего пользователя и создаем всплывающее сообщение об успешном удалении
+        logout(request)
+        messages.add_message(request, messages.SUCCESS, 'Пользователь удален')
+        return super().post(request, *args, **kwargs)
+
+    def get_object(self, queryset=None): # Отыскиваем по ключу пользователя, подлежащего удалению
+        if not queryset:
+            queryset = self.get_queryset()
+        return get_object_or_404(queryset, pk=self.user_id)
+
+def by_rubric(request, pk):
+    rubric = get_object_or_404(SubRubric, pk=pk) # Название рубрики
+    ads = Ad.objects.filter(is_active=True, rubric=pk) # Объявления, относящиеся к рубрике
+    keyword = request.GET.get('keyword', False)
+    if keyword:
+        q = Q(title__icontains=keyword) | Q(content__icontains=keyword) # Фильтрация уже отобранных объявлений по введенному пользователю искомому слову
+        ads = ads.filter(q)
+    else:
+        keyword = ''
+    form = SearchForm(initial={'keyword': keyword}) # Вывод формы поиска
+    paginator = Paginator(ads, 2) # Пагинатор, выводящий по 2 объявления
+    page = request.GET.get('page', False)
+    if page:
+        page_num = page
+    else:
+        page_num = 1
+    page = paginator.get_page(page_num)
+    context = {'rubric': rubric, 'page': page, 'ads': page.object_list, 'form': form}
+    return render(request, 'main/by_rubric.html', context)
+
+def detail(request, rubric_pk, pk):
+    ad = Ad.objects.get(pk=pk)
+    ais = ad.additionalimage_set.all()
+    comments = Comment.objects.filter(ad=pk, is_active=True)
+    initial = {'ad': ad.pk} # В поле ad создаваемой формы ввода комментария заносим ключ выводящегося на странице объявления. С этим объявлением будет связан комментарий.
+    if request.user.is_authenticated:
+        initial['author'] = request.user.username
+        form_class = UserCommentForm
+    else:
+        form_class = GuestCommentForm
+    form = form_class(initial=initial)
+    if request.method == 'POST':
+        c_form = form_class(request.POST)
+        if c_form.is_valid():
+            c_form.save()
+            messages.add_message(request, messages.SUCCESS, 'Комментарий добавлен')
+        else:
+            form = c_form # Переносим форму из переменной c_form в переменную form. Эта форма, хранящая некорректные данные и сообщения об ошибках ввода, будет выведена на экран,
+                          # и посетитель сразу увидит, что он ввел не так.
+            messages.add_message(request, messages.WARNING, 'Комментарий не добавлен')
+    context = {'ad': ad, 'ais': ais, 'comments': comments, 'form': form}
+    return render(request, 'main/detail.html', context)
+
+@login_required
+def profile_ad_detail(request, pk):
+    ad = get_object_or_404(Ad, pk=pk)
+    ais = ad.additionalimage_set.all()
+    comments = Comment.objects.filter(ad=pk, is_active=True)
+    context = {'ad': ad, 'ais': ais, 'comments': comments}
+    return render(request, 'main/profile_ad_detail.html', context)
+
+@login_required
+def profile_ad_add(request):
+    if request.method == 'POST':
+        form = AdForm(request.POST, request.FILES)
+        if form.is_valid():
+            ad = form.save()
+            formset = AIFormSet(request.POST, request.FILES, instance=ad)
+            if formset.is_valid():
+                formset.save()
+                messages.add_message(request, messages.SUCCESS, 'Объявление добавлено')
+                return redirect('main:profile')
+    else:
+        form = AdForm(initial={'author': request.user.pk})
+        formset = AIFormSet()
+    context = {'form': form, 'formset': formset}
+    return render(request, 'main/profile_ad_add.html', context)
+
+@login_required
+def profile_ad_change(request, pk):
+    ad = get_object_or_404(Ad, pk=pk)
+    if request.method == 'POST':
+        form = AdForm(request.POST, request.FILES, instance=ad)
+        if form.is_valid():
+            ad = form.save()
+            formset = AIFormSet(request.POST, request.FILES, instance=ad)
+            if formset.is_valid():
+                formset.save()
+                messages.add_message(request, messages.SUCCESS, 'Объявление исправлено')
+                return redirect('main:profile')
+    else:
+        form = AdForm(instance=ad)
+        formset = AIFormSet(instance=ad)
+    context = {'form': form, 'formset': formset}
+    return render(request, 'main/profile_ad_change.html', context)
+
+@login_required
+def profile_ad_delete(request, pk):
+    ad = get_object_or_404(Ad, pk=pk)
+    if request.method == 'POST':
+        ad.delete()
+        messages.add_message(request, messages.SUCCESS, 'Объявление удалено')
+        return redirect('main:profile')
+    else:
+        context = {'ad': ad}
+        return render(request, 'main/profile_ad_delete.html', context)
+
+

+ 22 - 0
manage.py

@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+    """Run administrative tasks."""
+    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'board.settings')
+    try:
+        from django.core.management import execute_from_command_line
+    except ImportError as exc:
+        raise ImportError(
+            "Couldn't import Django. Are you sure it's installed and "
+            "available on your PYTHONPATH environment variable? Did you "
+            "forget to activate a virtual environment?"
+        ) from exc
+    execute_from_command_line(sys.argv)
+
+
+if __name__ == '__main__':
+    main()

BIN
media/1673036695.980743.jpg


BIN
media/1673036695.992017.jpg


BIN
media/1673036695.994043.jpg


BIN
media/1673037135.579345.jpg


BIN
media/1673037135.583253.jpg


BIN
media/1673208732.510287.jpeg


BIN
media/thumbnails/1673036695.980743.jpg.96x96_q85_crop-scale.jpg


BIN
media/thumbnails/1673037135.579345.jpg.96x96_q85_crop-scale.jpg


BIN
media/thumbnails/1673208732.510287.jpeg.96x96_q85_crop-scale.jpg