diff --git a/.env.docker b/.env.docker
new file mode 100644
index 0000000..4b358b2
--- /dev/null
+++ b/.env.docker
@@ -0,0 +1,13 @@
+SECRET_KEY=django-insecure-n7=j$-o2$-ae1_xw5$ers@t*zvad97mwo$tjf^imow524_8#=o
+DEBUG=True
+
+POSTGRES_DB=askme
+POSTGRES_USER=askme_user
+POSTGRES_PASSWORD=askme_password
+
+DB_NAME=askme
+DB_USER=askme_user
+DB_PASSWORD=askme_password
+DB_HOST=db
+DB_PORT=5432
+DB_ENGINE="django.db.backends.postgresql"
\ No newline at end of file
diff --git a/.env.example b/.env.example
index 435e057..f60c574 100644
--- a/.env.example
+++ b/.env.example
@@ -1,2 +1,9 @@
SECRET_KEY=django-insecure-1234
-DEBUG=True
\ No newline at end of file
+DEBUG=True
+
+DB_NAME=vkdesk
+DB_USER=postgres
+DB_PASSWORD=1234567
+DB_HOST=localhost
+DB_PORT=5432
+DB_ENGINE="django.db.backends.postgresql"
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 4906332..0ae6f79 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,15 +1,14 @@
-FROM python:3.10-slim
+FROM python:3.11
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
-COPY ./requirements.txt ./
-RUN pip install --no-cache-dir -r requirements.txt
+COPY requirements.txt .
-COPY . .
+RUN pip install --upgrade pip
-EXPOSE 8000
+RUN pip install -r requirements.txt
-CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
+COPY . .
\ No newline at end of file
diff --git a/README.md b/README.md
index 45dd9df..0dde39c 100644
--- a/README.md
+++ b/README.md
@@ -10,31 +10,18 @@
🔧 Студент VK Education, курса "Web-разработка".
-## HW-1:
-- создан репозиторий и подкаталоги внутри
-- создана базовая структура проекта (/public/*.html, /public/static/css/style.css, /public/static/js/script.js)
-- подключена библиотека bootstrap локально (/public/static/css/bootstrap.min.css) (пока не использовалась, подключена на будущее)
-- настроены права (protected main branch + merge только через PR)
-
-
-
-- настроен .gitignore (за основу взят шаблон для python)
-- сверстаны base.html, index.html, question.html, ask.html, login.html, signup.html, profile.html
->>>>>>> main
-
## Запуск проекта
-### 1. Установка корректных .env
+### На текущей машине
-Для отладки можно переименовать .env.example в .env
-
-### 2. На текущей машине
```shell
-pip install --no-cache-dir -r requirements.txt
python manage.py runserver 8000
+pip install --no-cache-dir -r requirements.txt
```
-### 2. Docker
+### Docker
+
+ВАЖНО! Использовать .env.docker вместо .env.example в качестве образца!
```shell
docker compose up --build
@@ -42,3 +29,13 @@ docker compose up --build
Запустится именно на 127.0.0.1:8000
+
+## Миграции и заполнение дб
+
+Заполнить ДБ 10000 рандомных данных
+
+```
+python manage.py makemigrations
+python manage.py migrate
+python manage.py fill_db 10000
+```
\ No newline at end of file
diff --git a/application/settings.py b/application/settings.py
index 78e374b..c7d8dbd 100644
--- a/application/settings.py
+++ b/application/settings.py
@@ -38,6 +38,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
+ 'questions',
]
MIDDLEWARE = [
@@ -80,8 +81,12 @@ WSGI_APPLICATION = 'application.wsgi.application'
DATABASES = {
'default': {
- 'ENGINE': 'django.db.backends.sqlite3',
- 'NAME': BASE_DIR / 'db.sqlite3',
+ 'ENGINE': os.getenv('DB_ENGINE'),
+ 'NAME': os.getenv('DB_NAME'),
+ 'USER': os.getenv('DB_USER'),
+ 'PASSWORD': os.getenv('DB_PASSWORD'),
+ 'HOST': os.getenv('DB_HOST'),
+ 'PORT': os.getenv('DB_PORT'),
}
}
@@ -108,7 +113,7 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
-LANGUAGE_CODE = 'en-us'
+LANGUAGE_CODE = 'ru-RU'
TIME_ZONE = 'UTC'
@@ -128,3 +133,7 @@ STATIC_ROOT = os.path.join(BASE_DIR, 'static')
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+
+
+MEDIA_URL = '/media/'
+MEDIA_ROOT = BASE_DIR / 'media'
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 7d027e6..38eb7b9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,11 +1,70 @@
version: '3.8'
services:
+
+ db:
+
+ image: postgres:16
+
+ container_name: postgres-container
+
+ restart: always
+
+ env_file:
+ - .env
+
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB}
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+
+ networks:
+ - django-network
+
+
+
python-app:
+
build: .
+
container_name: django-app-container
+
+ restart: always
+
ports:
- "8000:8000"
+
+ working_dir: /app
+
volumes:
- .:/app
- working_dir: /app
+ - media_data:/app/media
+
+ env_file:
+ - .env
+
+ depends_on:
+ - db
+
+ command: >
+ sh -c "
+ python manage.py migrate &&
+ python manage.py runserver 0.0.0.0:8000
+ "
+
+ networks:
+ - django-network
+
+
+
+volumes:
+ postgres_data:
+ media_data:
+
+
+
+networks:
+ django-network:
\ No newline at end of file
diff --git a/questions/forms.py b/questions/forms.py
new file mode 100644
index 0000000..0f96e1d
--- /dev/null
+++ b/questions/forms.py
@@ -0,0 +1,173 @@
+from django import forms
+from django.contrib.auth import authenticate
+from django.contrib.auth.models import User
+from django.contrib.auth.password_validation import validate_password
+from django.core.exceptions import ValidationError
+
+import os
+
+from .models import Question, Tag
+from .models import Profile
+from .models import Answer
+
+def validate_avatar(image):
+ max_size = 2 * 1024 * 1024
+
+ if image.size > max_size:
+ raise ValidationError("Image too large (max 2MB)")
+
+ ext = os.path.splitext(image.name)[1].lower()
+
+ allowed_extensions = ['.jpg', '.jpeg', '.png', '.webp', '.ico']
+
+ if ext not in allowed_extensions:
+ raise ValidationError("Unsupported file extension")
+
+class LoginForm(forms.Form):
+ username = forms.CharField(max_length=150)
+ password = forms.CharField(widget=forms.PasswordInput)
+
+ def clean(self):
+ cleaned_data = super().clean()
+
+ username = cleaned_data.get('username')
+ password = cleaned_data.get('password')
+
+ user = authenticate(username=username, password=password)
+
+ if not user:
+ raise ValidationError("Wrong login or password")
+
+ cleaned_data['user'] = user
+ return cleaned_data
+
+
+class SignupForm(forms.Form):
+ username = forms.CharField(max_length=150)
+ email = forms.EmailField()
+ nickname = forms.CharField(max_length=100)
+
+ password = forms.CharField(widget=forms.PasswordInput)
+ password_repeat = forms.CharField(widget=forms.PasswordInput)
+
+ avatar = forms.ImageField(
+ required=False,
+ validators=[validate_avatar]
+ )
+
+ def clean_username(self):
+ username = self.cleaned_data['username']
+ if User.objects.filter(username=username).exists():
+ raise ValidationError("Username already exists")
+ return username
+
+ def clean_email(self):
+ email = self.cleaned_data['email']
+ if Profile.objects.filter(email=email).exists():
+ raise ValidationError("Email already registered")
+ return email
+
+ def clean(self):
+ cleaned_data = super().clean()
+
+ p1 = cleaned_data.get('password')
+ p2 = cleaned_data.get('password_repeat')
+
+ if p1 != p2:
+ raise ValidationError("Passwords do not match")
+
+ validate_password(p1)
+
+ return cleaned_data
+
+ def save(self):
+ user = User.objects.create_user(
+ username=self.cleaned_data['username'],
+ password=self.cleaned_data['password']
+ )
+
+ Profile.objects.create(
+ user=user,
+ email=self.cleaned_data['email'],
+ nickname=self.cleaned_data['nickname'],
+ avatar=self.cleaned_data.get('avatar')
+ )
+
+ return user
+
+
+class ProfileForm(forms.Form):
+ email = forms.EmailField()
+ nickname = forms.CharField(max_length=100)
+ avatar = forms.ImageField(
+ required=False,
+ validators=[validate_avatar]
+ )
+
+ def __init__(self, *args, user=None, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.user = user
+
+ if user:
+ self.fields['email'].initial = user.profile.email
+ self.fields['nickname'].initial = user.profile.nickname
+
+ def clean_email(self):
+ email = self.cleaned_data['email']
+
+ if Profile.objects.filter(email=email).exclude(user=self.user).exists():
+ raise ValidationError("Email already used")
+
+ return email
+
+ def save(self):
+ profile = self.user.profile
+
+ profile.email = self.cleaned_data['email']
+ profile.nickname = self.cleaned_data['nickname']
+
+ if self.cleaned_data.get('avatar'):
+ profile.avatar = self.cleaned_data['avatar']
+
+ profile.save()
+
+class AskForm(forms.ModelForm):
+ tags = forms.CharField()
+
+ class Meta:
+ model = Question
+ fields = ['title', 'body']
+
+ def clean_tags(self):
+ tags = self.cleaned_data['tags'].split(',')
+ tags = [t.strip() for t in tags if t.strip()]
+
+ if not tags:
+ raise ValidationError("Add at least one tag")
+
+ return tags
+
+ def save(self, user):
+ question = super().save(commit=False)
+ question.user = user
+ question.save()
+
+ tags = self.cleaned_data['tags']
+
+ for tag_name in tags:
+ tag, _ = Tag.objects.get_or_create(name=tag_name)
+ question.tags.add(tag)
+
+ return question
+
+class AnswerForm(forms.ModelForm):
+ class Meta:
+ model = Answer
+ fields = ['body']
+
+ def save(self, user, question):
+ answer = super().save(commit=False)
+ answer.user = user
+ answer.question = question
+ answer.save()
+ return answer
\ No newline at end of file
diff --git a/questions/management/__init__.py b/questions/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/questions/management/commands/__init__.py b/questions/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/questions/management/commands/fill_db.py b/questions/management/commands/fill_db.py
new file mode 100644
index 0000000..d67bb8d
--- /dev/null
+++ b/questions/management/commands/fill_db.py
@@ -0,0 +1,189 @@
+import random
+
+from django.core.management.base import BaseCommand
+from django.contrib.auth.models import User
+from faker import Faker
+
+from questions.models import (
+ Question,
+ Answer,
+ Tag,
+ Profile,
+ QuestionReaction,
+)
+
+fake = Faker()
+
+
+class Command(BaseCommand):
+ help = 'Fill database with test data'
+
+ def add_arguments(self, parser):
+ parser.add_argument('ratio', type=int)
+
+ def handle(self, *args, **options):
+ ratio = options['ratio']
+
+ self.stdout.write(
+ self.style.SUCCESS(f'Start filling DB with ratio={ratio}')
+ )
+
+ self.clear_db()
+
+ users = self.create_users(ratio)
+ tags = self.create_tags(ratio)
+ questions = self.create_questions(ratio, users, tags)
+
+ self.create_answers(ratio, users, questions)
+ self.create_reactions(ratio, users, questions)
+
+ self.stdout.write(self.style.SUCCESS('DONE'))
+
+ def clear_db(self):
+ QuestionReaction.objects.all().delete()
+ Answer.objects.all().delete()
+ Question.objects.all().delete()
+ Profile.objects.all().delete()
+ Tag.objects.all().delete()
+
+ # если нужен superuser — можно удалить эту строку
+ User.objects.filter(is_superuser=False).delete()
+
+ def create_users(self, ratio):
+ fake.unique.clear()
+
+ users = []
+
+ for i in range(ratio):
+ users.append(
+ User(
+ username=f'user_{i}_{fake.user_name()}',
+ email=fake.unique.email(),
+ )
+ )
+
+ User.objects.bulk_create(users, batch_size=1000)
+
+ users = list(User.objects.all())
+
+ profiles = [
+ Profile(
+ user=user,
+ email=user.email,
+ nickname=fake.user_name(),
+ )
+ for user in users
+ ]
+
+ Profile.objects.bulk_create(profiles, batch_size=1000)
+
+ self.stdout.write(self.style.SUCCESS('Users created'))
+
+ return users
+
+ def create_tags(self, ratio):
+ fake.unique.clear()
+
+ tag_count = max(20, ratio // 100)
+
+ tags = [
+ Tag(name=fake.unique.word())
+ for _ in range(tag_count)
+ ]
+
+ Tag.objects.bulk_create(tags, batch_size=1000)
+
+ tags = list(Tag.objects.all())
+
+ self.stdout.write(self.style.SUCCESS('Tags created'))
+
+ return tags
+
+ def create_questions(self, ratio, users, tags):
+ questions = [
+ Question(
+ title=fake.sentence()[:50],
+ body=fake.text(max_nb_chars=500),
+ user=random.choice(users),
+ rating=random.randint(0, 100),
+ )
+ for _ in range(ratio)
+ ]
+
+ Question.objects.bulk_create(questions, batch_size=1000)
+
+ questions = list(Question.objects.all())
+
+ # BULK CREATE ДЛЯ MANY-TO-MANY
+ through_model = Question.tags.through
+ relations = []
+
+ for question in questions:
+ selected_tags = random.sample(
+ tags,
+ k=min(3, len(tags))
+ )
+
+ for tag in selected_tags:
+ relations.append(
+ through_model(
+ question_id=question.id,
+ tag_id=tag.id,
+ )
+ )
+
+ through_model.objects.bulk_create(
+ relations,
+ batch_size=1000
+ )
+
+ self.stdout.write(self.style.SUCCESS('Questions created'))
+
+ return questions
+
+ def create_answers(self, ratio, users, questions):
+ answers = [
+ Answer(
+ question=random.choice(questions),
+ user=random.choice(users),
+ body=fake.text(max_nb_chars=300),
+ rating=random.randint(0, 50),
+ )
+ for _ in range(ratio)
+ ]
+
+ Answer.objects.bulk_create(answers, batch_size=1000)
+
+ self.stdout.write(self.style.SUCCESS('Answers created'))
+
+ def create_reactions(self, ratio, users, questions):
+ reactions = []
+ used_pairs = set()
+
+ target = ratio
+
+ while len(reactions) < target:
+ user = random.choice(users)
+ question = random.choice(questions)
+
+ pair = (user.id, question.id)
+
+ if pair in used_pairs:
+ continue
+
+ used_pairs.add(pair)
+
+ reactions.append(
+ QuestionReaction(
+ question=question,
+ user=user,
+ positive=random.choice([True, False]),
+ )
+ )
+
+ QuestionReaction.objects.bulk_create(
+ reactions,
+ batch_size=1000
+ )
+
+ self.stdout.write(self.style.SUCCESS('Reactions created'))
\ No newline at end of file
diff --git a/questions/migrations/0001_initial.py b/questions/migrations/0001_initial.py
new file mode 100644
index 0000000..e035840
--- /dev/null
+++ b/questions/migrations/0001_initial.py
@@ -0,0 +1,68 @@
+# Generated by Django 5.2.12 on 2026-04-27 17:22
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Tag',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=50, unique=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Profile',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')),
+ ('email', models.EmailField(max_length=254, unique=True)),
+ ('nickname', models.CharField(max_length=100)),
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Question',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(max_length=50)),
+ ('body', models.TextField()),
+ ('rating', models.IntegerField(default=0)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('tags', models.ManyToManyField(to='questions.tag')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Answer',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('body', models.TextField()),
+ ('rating', models.IntegerField(default=0)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='questions.question')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='QuestionReaction',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('positive', models.BooleanField(default=True)),
+ ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='questions.question')),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'unique_together': {('question', 'user')},
+ },
+ ),
+ ]
diff --git a/questions/migrations/0002_alter_profile_avatar_answerreaction.py b/questions/migrations/0002_alter_profile_avatar_answerreaction.py
new file mode 100644
index 0000000..22395d2
--- /dev/null
+++ b/questions/migrations/0002_alter_profile_avatar_answerreaction.py
@@ -0,0 +1,34 @@
+# Generated by Django 5.2.12 on 2026-05-08 21:19
+
+import django.db.models.deletion
+import questions.models
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('questions', '0001_initial'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='profile',
+ name='avatar',
+ field=models.ImageField(blank=True, null=True, upload_to=questions.models.avatar_upload_path),
+ ),
+ migrations.CreateModel(
+ name='AnswerReaction',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('positive', models.BooleanField(default=True)),
+ ('answer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='questions.answer')),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'unique_together': {('answer', 'user')},
+ },
+ ),
+ ]
diff --git a/questions/migrations/0003_question_correct_answer.py b/questions/migrations/0003_question_correct_answer.py
new file mode 100644
index 0000000..94bda74
--- /dev/null
+++ b/questions/migrations/0003_question_correct_answer.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.2.12 on 2026-05-10 13:57
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('questions', '0002_alter_profile_avatar_answerreaction'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='question',
+ name='correct_answer',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='correct_for', to='questions.answer'),
+ ),
+ ]
diff --git a/questions/models.py b/questions/models.py
index 71a8362..d337727 100644
--- a/questions/models.py
+++ b/questions/models.py
@@ -1,3 +1,160 @@
from django.db import models
+from django.contrib.auth.models import User
-# Create your models here.
+import uuid
+import os
+
+from .utils import crop_square
+
+
+def avatar_upload_path(instance, filename):
+
+ ext = filename.split('.')[-1]
+
+ filename = f"{uuid.uuid4()}.{ext}"
+
+ return os.path.join('avatars', filename)
+
+
+class Profile(models.Model):
+
+ user = models.OneToOneField(
+ User,
+ on_delete=models.CASCADE
+ )
+
+ avatar = models.ImageField(
+ upload_to=avatar_upload_path,
+ null=True,
+ blank=True
+ )
+
+ email = models.EmailField(unique=True)
+
+ nickname = models.CharField(max_length=100)
+
+ def __str__(self):
+ return self.user.username
+
+ def save(self, *args, **kwargs):
+
+ super().save(*args, **kwargs)
+
+ if self.avatar:
+ crop_square(self.avatar.path)
+
+
+class Tag(models.Model):
+
+ name = models.CharField(
+ max_length=50,
+ unique=True
+ )
+
+ def __str__(self):
+ return self.name
+
+
+class Question(models.Model):
+
+ title = models.CharField(max_length=50)
+
+ user = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE
+ )
+
+ body = models.TextField()
+
+ rating = models.IntegerField(default=0)
+
+ tags = models.ManyToManyField(Tag)
+
+ created_at = models.DateTimeField(
+ auto_now_add=True
+ )
+
+ correct_answer = models.ForeignKey(
+ 'Answer',
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='correct_for'
+ )
+
+ def __str__(self):
+ return self.title
+
+
+class QuestionReaction(models.Model):
+
+ question = models.ForeignKey(
+ Question,
+ on_delete=models.CASCADE,
+ related_name='reactions'
+ )
+
+ user = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE
+ )
+
+ positive = models.BooleanField(default=True)
+
+ class Meta:
+ unique_together = ('question', 'user')
+
+ def __str__(self):
+
+ return (
+ "Reaction for "
+ + self.question.title
+ + " by "
+ + self.user.username
+ )
+
+
+class Answer(models.Model):
+
+ question = models.ForeignKey(
+ Question,
+ on_delete=models.CASCADE,
+ related_name='answers'
+ )
+
+ user = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE
+ )
+
+ body = models.TextField()
+
+ rating = models.IntegerField(default=0)
+
+ def __str__(self):
+
+ return (
+ "Answer for "
+ + self.question.title
+ + " by "
+ + self.user.username
+ )
+
+
+class AnswerReaction(models.Model):
+
+ answer = models.ForeignKey(
+ Answer,
+ on_delete=models.CASCADE,
+ related_name='reactions'
+ )
+
+ user = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE
+ )
+
+ positive = models.BooleanField(default=True)
+
+ class Meta:
+ unique_together = ('answer', 'user')
\ No newline at end of file
diff --git a/questions/static/core/js/script.js b/questions/static/core/js/script.js
index e69de29..4c9ac84 100644
--- a/questions/static/core/js/script.js
+++ b/questions/static/core/js/script.js
@@ -0,0 +1,182 @@
+function getCookie(name) {
+ let cookieValue = null;
+ if (document.cookie && document.cookie !== '') {
+ const cookies = document.cookie.split(';');
+ for (let cookie of cookies) {
+ cookie = cookie.trim();
+ if (cookie.startsWith(name + '=')) {
+ cookieValue = decodeURIComponent(
+ cookie.substring(name.length + 1)
+ );
+ break;
+ }
+ }
+ }
+ return cookieValue;
+}
+
+const csrftoken = getCookie('csrftoken');
+
+$(document).on('click', '.question-like', function () {
+ const btn = $(this);
+ const questionId = btn.data('id');
+ $.ajax({
+ url: '/ajax/question/react/',
+ type: 'POST',
+ headers: {
+ 'X-CSRFToken': csrftoken
+ },
+ data: {
+ question_id: questionId,
+ type: 'like'
+ },
+ success: function (response) {
+ $('#question-rating-' + questionId)
+ .text(response.rating);
+ btn.remove();
+ $('.question-dislike[data-id="' + questionId + '"]')
+ .remove();
+ $('#question-rating-' + questionId)
+ .after(' voted ');
+ },
+ error: function (xhr) {
+ if (xhr.responseJSON) {
+ alert(xhr.responseJSON.error);
+ } else {
+ alert('Server error');
+ }
+ }
+ });
+});
+
+$(document).on('click', '.question-dislike', function () {
+ const btn = $(this);
+ const questionId = btn.data('id');
+ $.ajax({
+ url: '/ajax/question/react/',
+ type: 'POST',
+ headers: {
+ 'X-CSRFToken': csrftoken
+ },
+ data: {
+ question_id: questionId,
+ type: 'dislike'
+ },
+ success: function (response) {
+ $('#question-rating-' + questionId)
+ .text(response.rating);
+ btn.remove();
+ $('.question-like[data-id="' + questionId + '"]')
+ .remove();
+ $('#question-rating-' + questionId)
+ .after(' voted ');
+ }
+ error: function (xhr) {
+ if (xhr.responseJSON) {
+ alert(xhr.responseJSON.error);
+ } else {
+ alert('Server error');
+ }
+ }
+ });
+});
+
+$(document).on('click', '.answer-like', function () {
+ const btn = $(this);
+ const answerId = btn.data('id');
+ $.ajax({
+ url: '/ajax/answer/react/',
+ type: 'POST',
+ headers: {
+ 'X-CSRFToken': csrftoken
+ },
+ data: {
+ answer_id: answerId,
+ type: 'like'
+ }
+ success: function (response) {
+ $('#answer-rating-' + answerId)
+ .text(response.rating);
+ btn.remove();
+ $('.answer-dislike[data-id="' + answerId + '"]')
+ .remove();
+ $('#answer-rating-' + answerId)
+ .after(' voted ');
+ },
+ error: function (xhr) {
+ if (xhr.responseJSON) {
+ alert(xhr.responseJSON.error);
+ } else {
+ alert('Server error');
+ }
+ }
+ });
+});
+
+$(document).on('click', '.answer-dislike', function () {
+ const btn = $(this);
+ const answerId = btn.data('id');
+ $.ajax({
+ url: '/ajax/answer/react/',
+ type: 'POST',
+ headers: {
+ 'X-CSRFToken': csrftoken
+ },
+ data: {
+ answer_id: answerId,
+ type: 'dislike'
+ },
+ success: function (response) {
+ $('#answer-rating-' + answerId)
+ .text(response.rating);
+ btn.remove();
+ $('.answer-like[data-id="' + answerId + '"]')
+ .remove();
+
+ $('#answer-rating-' + answerId)
+ .after(' voted ');
+ },
+
+ error: function (xhr) {
+
+ if (xhr.responseJSON) {
+ alert(xhr.responseJSON.error);
+ } else {
+ alert('Server error');
+ }
+ }
+ });
+});
+
+
+$(document).on('click', '.correct-answer-btn', function () {
+ const btn = $(this);
+ $.ajax({
+ url: '/ajax/answer/correct/',
+ type: 'POST',
+ headers: {
+ 'X-CSRFToken': csrftoken
+ },
+ data: {
+ question_id: btn.data('question'),
+ answer_id: btn.data('answer')
+ },
+ success: function (response) {
+ $('.correct-label').remove();
+ $('#answer-' + response.correct_answer_id)
+ .find('.answer-controls')
+ .html(`
+
+ ✔ Correct
+
+ `);
+ },
+ error: function (xhr) {
+ if (xhr.responseJSON) {
+ alert(xhr.responseJSON.error);
+ } else {
+ alert('Server error');
+ }
+ }
+ });
+});
\ No newline at end of file
diff --git a/questions/templates/core/ask.html b/questions/templates/core/ask.html
index 5d02db9..c8b53fc 100644
--- a/questions/templates/core/ask.html
+++ b/questions/templates/core/ask.html
@@ -6,22 +6,29 @@