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) -image -image -image -- настроен .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 @@

Ask a Question

+
+ {% csrf_token %} + + {{ form.non_field_errors }} -
- - + + {{ form.title }} + {{ form.title.errors }}
+
- - + + {{ form.body }} + {{ form.body.errors }}
+
- - -
-
- + + {{ form.tags }} + {{ form.tags.errors }}
+ +
{% endblock %} diff --git a/questions/templates/core/layout/base.html b/questions/templates/core/layout/base.html index 2b2ad3e..9dc7ccf 100644 --- a/questions/templates/core/layout/base.html +++ b/questions/templates/core/layout/base.html @@ -6,6 +6,7 @@ VkDesk | Fresh Questions + @@ -39,21 +54,11 @@ {% endfor %} - - + diff --git a/questions/templates/core/login.html b/questions/templates/core/login.html index 4f8901a..5482a82 100644 --- a/questions/templates/core/login.html +++ b/questions/templates/core/login.html @@ -8,21 +8,31 @@

Sign In

-

Sorry, wrong password!

-
+ + {% csrf_token %} + + {{ form.non_field_errors }} +
- - + + {{ form.username }} + {{ form.username.errors }}
+
- - + + {{ form.password }} + {{ form.password.errors }}
+ + +
- +
- create new account + + create new account
diff --git a/questions/templates/core/partials/question.html b/questions/templates/core/partials/question.html index 5a8678d..94594c1 100644 --- a/questions/templates/core/partials/question.html +++ b/questions/templates/core/partials/question.html @@ -1,21 +1,88 @@ -
+
-
avatar
+ +
+ + {% if question.user.profile.avatar %} + + {% else %} + avatar + {% endif %} + +
+
- 5 - ^ + + + {{ question.rating }} + + + {% if request.user.is_authenticated %} + + {% if question.id not in user_reactions %} + + + + + + {% else %} + + voted + + {% endif %} + + {% endif %} +
+
+
- {{ question.title }} -

{{ question.text }}

+ + + {{ question.title }} + + +

{{ question.body }}

+
- answer (3) + + + answer + + Tags: - {% for tag in question.tags %} - {{ tag }} + + {% for tag in question.tags.all %} + + + {{ tag }} + + {% endfor %} +
+
-
+ + \ No newline at end of file diff --git a/questions/templates/core/profile.html b/questions/templates/core/profile.html index 9f75f5a..f0459ce 100644 --- a/questions/templates/core/profile.html +++ b/questions/templates/core/profile.html @@ -6,30 +6,37 @@

Profile: Dr. Pepper

-
+ + {% csrf_token %} + + {{ form.non_field_errors }} +
- - + + {{ form.email }} + {{ form.email.errors }}
+
- - + + {{ form.nickname }} + {{ form.nickname.errors }}
+
- - + + {{ form.avatar }}
-
- -
avatar
-
-
- - -
-
- + +
+ {% if request.user.profile.avatar %} + + {% else %} + + {% endif %}
+ + {% endblock %} \ No newline at end of file diff --git a/questions/templates/core/question.html b/questions/templates/core/question.html index d0cfbf4..f91c429 100644 --- a/questions/templates/core/question.html +++ b/questions/templates/core/question.html @@ -4,63 +4,187 @@ {% block content %}
+
-
avatar
-
- 5 - ^' + +
+ + {% if question.user.profile.avatar %} + + {% else %} + avatar + {% endif %} +
+ +
+ + + {{ question.rating }} + + + {% if request.user.is_authenticated %} + + {% if not question_reaction %} + + + + + + {% else %} + + voted + + {% endif %} + + {% endif %} + +
+
+
-
How to build a moon park ?
-

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.

+ +
+ {{ question.title }} +
+ +

{{ question.body }}

+
+ Tags: - black-jack - bender + + {% for tag in question.tags.all %} + + + {{ tag }} + + + {% endfor %} +
+
+
-
+ +{% for answer in answers %} + +
+
-
avatar
+ +
+ + {% if answer.user.profile.avatar %} + + {% else %} + avatar + {% endif %} + +
+
- 5 - ^' + + + {{ answer.rating }} + + + {% if request.user.is_authenticated %} + + {% if answer.id not in answer_reactions %} + + + + + + {% else %} + + voted + + {% endif %} + + {% endif %} +
+
+
-

First of all I would like to thank you for the invitation to participate in such a ... Russia is the huge territory which in many respects needs to be render habitable.

+ +

{{ answer.body }}

+
- - Correct! + + {% if question.correct_answer_id == answer.id %} + + + ✔ Correct + + + {% endif %} +
+ + {% if request.user == question.user %} + + + + {% endif %} +
+
-
-
-
avatar
-
- 3 - ^' -
-
-
-

First of all I would like to thank you for the invitation to participate in such a ... Russia is the huge territory which in many respects needs to be render habitable.

-
- - Correct! -
-
-
+{% endfor %} + + +
+ + {% csrf_token %} + + {{ form.body }} + + {{ form.body.errors }} + + - - - -
- {% endblock %} \ No newline at end of file diff --git a/questions/templates/core/signup.html b/questions/templates/core/signup.html index a4af329..7a627fc 100644 --- a/questions/templates/core/signup.html +++ b/questions/templates/core/signup.html @@ -7,38 +7,51 @@

Create Account

-

Sorry, this email address already registered!

+
+ {% csrf_token %} + + {{ form.non_field_errors }} -
- - + + {{ form.username }} + {{ form.username.errors }}
+
- - + + {{ form.email }} + {{ form.email.errors }}
+
- - + + {{ form.nickname }} + {{ form.nickname.errors }}
+
- - + + {{ form.password }} + {{ form.password.errors }}
+
- - + + {{ form.password_repeat }} + {{ form.password_repeat.errors }}
+
- - + + {{ form.avatar }} + {{ form.avatar.errors }}
+
- +
- {% endblock %} diff --git a/questions/urls.py b/questions/urls.py index 5a394e0..d5cd0ee 100644 --- a/questions/urls.py +++ b/questions/urls.py @@ -1,4 +1,6 @@ from django.urls import path +from django.conf import settings +from django.conf.urls.static import static from questions import views @@ -10,4 +12,11 @@ urlpatterns = [ path('question', views.question, name='question'), path('signup', views.signup, name='signup'), path('hot', views.hot, name='hot'), -] \ No newline at end of file + path('logout', views.logout, name='logout'), + path('ajax/question/react/', views.question_react), + path('ajax/answer/react/', views.answer_react), + path('ajax/answer/correct/', views.mark_correct), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/questions/utils.py b/questions/utils.py new file mode 100644 index 0000000..70f53ae --- /dev/null +++ b/questions/utils.py @@ -0,0 +1,20 @@ +from PIL import Image + + +def crop_square(image_path, size=(300, 300)): + img = Image.open(image_path) + + width, height = img.size + + min_side = min(width, height) + + left = (width - min_side) / 2 + top = (height - min_side) / 2 + right = (width + min_side) / 2 + bottom = (height + min_side) / 2 + + img = img.crop((left, top, right, bottom)) + + img = img.resize(size) + + img.save(image_path) \ No newline at end of file diff --git a/questions/views.py b/questions/views.py index ff5967f..2e93cf6 100644 --- a/questions/views.py +++ b/questions/views.py @@ -1,49 +1,423 @@ -from django.shortcuts import render +from django.shortcuts import render, get_object_or_404 from django.core.paginator import Paginator +from django.contrib.auth import login as auth_login, logout as auth_logout +from django.shortcuts import redirect +from django.utils.http import url_has_allowed_host_and_scheme +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse +from django.views.decorators.http import require_POST +from .models import ( + Question, + Tag, + Answer, + QuestionReaction, + AnswerReaction +) -QUESTIONS = [ - { - "id": i, - "title": f'Question number {i}', - "text": f'Some text {i}', - "tags": ["python"], - } - for i in range(10) -] +from .forms import ( + LoginForm, + SignupForm, + ProfileForm, + AskForm, + AnswerForm +) -HOT_QUESTIONS = QUESTIONS[:4] - -TAGS = {tag for q in QUESTIONS for tag in q["tags"]} def index(request): - tag = request.GET.get('tag', '') - try: - page_number = int(request.GET.get('page', 1)) - except ValueError: - page_number = 1 - page = Paginator(QUESTIONS if tag == '' - else [ cur_question for cur_question in QUESTIONS if tag in cur_question['tags']], 4) - page_obj = page.page(page_number) - return render(request, 'core/index.html', context={'questions': page_obj.object_list, - 'hot_questions': HOT_QUESTIONS, 'page_obj': page_obj, 'tags': TAGS, - 'tag': tag}) + tag_name = request.GET.get('tag') + + questions = Question.objects.all() \ + .select_related('user') \ + .prefetch_related('tags') \ + .order_by('-created_at') + + if tag_name: + questions = questions.filter(tags__name=tag_name) + + paginator = Paginator(questions, 4) + + page_number = request.GET.get('page', 1) + + page_obj = paginator.get_page(page_number) + + user_reactions = {} + + if request.user.is_authenticated: + + reactions = QuestionReaction.objects.filter( + user=request.user, + question__in=page_obj.object_list + ) + + user_reactions = { + reaction.question_id: reaction.positive + for reaction in reactions + } + + hot_questions = Question.objects.order_by('-rating')[:4] + + return render(request, 'core/index.html', { + 'questions': page_obj.object_list, + 'page_obj': page_obj, + 'hot_questions': hot_questions, + 'tags': Tag.objects.all(), + 'tag': tag_name, + 'user_reactions': user_reactions + }) + -def ask(request): - return render(request, 'core/ask.html', context={'questions': QUESTIONS, 'tags': TAGS}) def hot(request): - return render(request, 'core/hot.html', context={'questions': QUESTIONS, 'tags': TAGS, - 'hot_questions': HOT_QUESTIONS,}) -def login(request): - return render(request, 'core/login.html', context={'questions': QUESTIONS, 'tags': TAGS}) + questions = Question.objects.all() \ + .select_related('user') \ + .prefetch_related('tags') \ + .order_by('-rating') + + paginator = Paginator(questions, 4) + + page_number = request.GET.get('page', 1) + + page_obj = paginator.get_page(page_number) + + user_reactions = {} + + if request.user.is_authenticated: + + reactions = QuestionReaction.objects.filter( + user=request.user, + question__in=page_obj.object_list + ) + + user_reactions = { + reaction.question_id: reaction.positive + for reaction in reactions + } + + return render(request, 'core/hot.html', { + 'questions': page_obj.object_list, + 'page_obj': page_obj, + 'hot_questions': questions[:4], + 'tags': Tag.objects.all(), + 'user_reactions': user_reactions + }) -def profile(request): - return render(request, 'core/profile.html', context={'questions': QUESTIONS, 'tags': TAGS}) def question(request): - return render(request, 'core/question.html', context={'questions': QUESTIONS, 'tags': TAGS}) + + question_id = request.GET.get('question_id') + + question = get_object_or_404( + Question.objects.select_related('user') + .prefetch_related('tags', 'answers'), + id=question_id + ) + + if request.method == 'POST': + + if not request.user.is_authenticated: + return redirect( + f'/login?next=/question?question_id={question_id}' + ) + + form = AnswerForm(request.POST) + + if form.is_valid(): + + answer = form.save( + user=request.user, + question=question + ) + + return redirect( + f'/question?question_id={question.id}#answer-{answer.id}' + ) + + else: + + form = AnswerForm() + + question_reaction = None + + answer_reactions = {} + + if request.user.is_authenticated: + + question_reaction = QuestionReaction.objects.filter( + question=question, + user=request.user + ).first() + + reactions = AnswerReaction.objects.filter( + user=request.user, + answer__question=question + ) + + answer_reactions = { + reaction.answer_id: reaction.positive + for reaction in reactions + } + + return render(request, 'core/question.html', { + 'question': question, + 'answers': question.answers.all(), + 'form': form, + 'tags': Tag.objects.all(), + 'question_reaction': question_reaction, + 'answer_reactions': answer_reactions + }) + + +def login(request): + + next_url = request.GET.get('next', '/') + + if request.method == 'POST': + + form = LoginForm(request.POST) + + if form.is_valid(): + + user = form.cleaned_data['user'] + + auth_login(request, user) + + if not url_has_allowed_host_and_scheme( + next_url, + allowed_hosts={request.get_host()} + ): + next_url = '/' + + return redirect(next_url) + + else: + + form = LoginForm() + + return render(request, 'core/login.html', { + 'form': form, + 'next': next_url, + 'tags': Tag.objects.all() + }) + def signup(request): - return render(request, 'core/signup.html', context={'questions': QUESTIONS, 'tags': TAGS}) \ No newline at end of file + + if request.method == 'POST': + + form = SignupForm(request.POST, request.FILES) + + if form.is_valid(): + + user = form.save() + + auth_login(request, user) + + return redirect('/') + + else: + + form = SignupForm() + + return render(request, 'core/signup.html', { + 'form': form, + 'tags': Tag.objects.all() + }) + + +def logout(request): + + auth_logout(request) + + return redirect( + request.META.get('HTTP_REFERER', '/') + ) + + +@login_required +def profile(request): + + if request.method == 'POST': + + form = ProfileForm( + request.POST, + request.FILES, + user=request.user + ) + + if form.is_valid(): + + form.save() + + return redirect('profile') + + else: + + form = ProfileForm(user=request.user) + + return render(request, 'core/profile.html', { + 'form': form, + 'tags': Tag.objects.all() + }) + + +@login_required +def ask(request): + + if request.method == 'POST': + + form = AskForm(request.POST) + + if form.is_valid(): + + question = form.save(user=request.user) + + return redirect( + f'/question?question_id={question.id}' + ) + + else: + + form = AskForm() + + return render(request, 'core/ask.html', { + 'form': form, + 'tags': Tag.objects.all() + }) + + +@require_POST +@login_required +def question_react(request): + + question_id = request.POST.get('question_id') + + reaction_type = request.POST.get('type') + + if reaction_type not in ['like', 'dislike']: + + return JsonResponse({ + 'error': 'invalid type' + }, status=400) + + question = get_object_or_404( + Question, + id=question_id + ) + + if QuestionReaction.objects.filter( + question=question, + user=request.user + ).exists(): + + return JsonResponse({ + 'error': 'already reacted' + }, status=400) + + positive = reaction_type == 'like' + + QuestionReaction.objects.create( + question=question, + user=request.user, + positive=positive + ) + + if positive: + question.rating += 1 + else: + question.rating -= 1 + + question.save() + + return JsonResponse({ + 'rating': question.rating + }) + + +@require_POST +@login_required +def answer_react(request): + + answer_id = request.POST.get('answer_id') + + reaction_type = request.POST.get('type') + + if reaction_type not in ['like', 'dislike']: + + return JsonResponse({ + 'error': 'invalid type' + }, status=400) + + answer = get_object_or_404( + Answer, + id=answer_id + ) + + if AnswerReaction.objects.filter( + answer=answer, + user=request.user + ).exists(): + + return JsonResponse({ + 'error': 'already reacted' + }, status=400) + + positive = reaction_type == 'like' + + AnswerReaction.objects.create( + answer=answer, + user=request.user, + positive=positive + ) + + if positive: + answer.rating += 1 + else: + answer.rating -= 1 + + answer.save() + + return JsonResponse({ + 'rating': answer.rating + }) + + +@require_POST +@login_required +def mark_correct(request): + + question_id = request.POST.get('question_id') + + answer_id = request.POST.get('answer_id') + + question = get_object_or_404( + Question, + id=question_id + ) + + answer = get_object_or_404( + Answer, + id=answer_id + ) + + if question.user != request.user: + + return JsonResponse({ + 'error': 'forbidden' + }, status=403) + + if answer.question != question: + + return JsonResponse({ + 'error': 'wrong answer' + }, status=400) + + question.correct_answer = answer + + question.save() + + return JsonResponse({ + 'success': True, + 'correct_answer_id': answer.id + }) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ffa41e1..3b753a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ Django==5.2.12 -python-dotenv==1.2.2 \ No newline at end of file +python-dotenv==1.2.2 +pillow==12.2.0 +psycopg2-binary==2.9.12