Skip to content

Commit

Permalink
feat: Badges (vas3k#704)
Browse files Browse the repository at this point in the history
* Badges draft

* Badges CSS for comments, posts, profiles

* Almost done, needs data and icons

* CSS fixes + frontend bugs

* More logical fixes

* Fixing bugs, rename things

* Add icons and initial data
  • Loading branch information
vas3k authored and glader committed Nov 1, 2021
1 parent 578392e commit 67e4d70
Show file tree
Hide file tree
Showing 70 changed files with 1,210 additions and 95 deletions.
Empty file added badges/__init__.py
Empty file.
6 changes: 6 additions & 0 deletions badges/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class BadgesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "badges"
50 changes: 50 additions & 0 deletions badges/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Generated by Django 3.2.5 on 2021-10-20 10:42

from django.db import migrations, models
import django.db.models.deletion
import uuid


class Migration(migrations.Migration):

initial = True

dependencies = [
('users', '0022_alter_user_secret_hash'),
('comments', '0008_auto_20210911_0827'),
('posts', '0024_postsubscription_type'),
]

operations = [
migrations.CreateModel(
name='Badge',
fields=[
('code', models.CharField(max_length=32, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('title', models.CharField(max_length=64)),
('description', models.CharField(max_length=256, null=True)),
('price_days', models.IntegerField(default=10)),
('is_visible', models.BooleanField(default=True)),
],
options={
'db_table': 'badges',
},
),
migrations.CreateModel(
name='UserBadge',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('note', models.TextField(null=True)),
('badge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_badges', to='badges.badge')),
('comment', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comment_badges', to='comments.comment')),
('from_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='from_badges', to='users.user')),
('post', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='post_badges', to='posts.post')),
('to_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='to_badges', to='users.user')),
],
options={
'db_table': 'user_badges',
'unique_together': {('from_user', 'to_user', 'badge', 'comment_id'), ('from_user', 'to_user', 'badge', 'post_id')},
},
),
]
28 changes: 28 additions & 0 deletions badges/migrations/0002_auto_20211022_0858.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 3.2.5 on 2021-10-22 08:58

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('badges', '0001_initial'),
]

operations = [
migrations.RunSQL("""
INSERT INTO "badges"("code","created_at","title","description","price_days","is_visible")
VALUES
('bad','2021-10-15 11:37:53.514167+02','Так плохо, что даже хорошо',NULL,110,FALSE),
('bmw','2021-10-15 11:37:53.514167+02','Беха','Выдать автору беху по программе помощи миллионерам',150,TRUE),
('cool','2021-10-15 11:37:53.514167+02','ОХУЕННО','Вот такой контент я хочу видеть в Клубе!',40,TRUE),
('cry','2021-10-15 11:37:53.514167+02','Я не плачу...','...это дождь',80,TRUE),
('dolor','2021-10-15 11:37:53.514167+02','Держи долор','Его заберёт налоговая, но нам останется ХоРоШеЕ НаСтРоЕнИе!',30,TRUE),
('hug','2021-10-15 11:37:53.514167+02','Дай обниму',NULL,60,TRUE),
('insight','2021-10-15 11:37:53.514167+02','Я понял!','Моя жизнь больше не будет прежней',50,TRUE),
('lov','2021-10-15 11:37:53.514167+02','Супер-любовь','За восстановление веры в человечество',80,TRUE),
('faang','2021-10-15 11:37:53.514167+02','Оффер в FAANG','Я бы тебя нанял',200,TRUE),
('year','2021-10-15 11:37:53.514167+02','Пост Года','Лучшее, что я читал в этом году',365,TRUE),
('lol','2021-10-15 11:37:53.514167+02','Как же я ору',NULL,60,TRUE);
""")
]
Empty file added badges/migrations/__init__.py
Empty file.
136 changes: 136 additions & 0 deletions badges/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import math
from datetime import timedelta
from uuid import uuid4

from django.db import models, transaction, IntegrityError
from django.db.models import F, Count

from club.exceptions import InsufficientFunds, BadRequest, ContentDuplicated
from comments.models import Comment
from posts.models.post import Post
from users.models.user import User


class Badge(models.Model):
code = models.CharField(primary_key=True, max_length=32, null=False, unique=True)
created_at = models.DateTimeField(auto_now_add=True)

title = models.CharField(max_length=64, null=False)
description = models.CharField(max_length=256, null=True)
price_days = models.IntegerField(default=10)
is_visible = models.BooleanField(default=True)

class Meta:
db_table = "badges"
ordering = ["price_days", "code"]

@classmethod
def visible_objects(cls):
return cls.objects.filter(is_visible=True)


class UserBadge(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)

badge = models.ForeignKey(Badge, related_name="user_badges", on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)

from_user = models.ForeignKey(User, related_name="from_badges", null=True, on_delete=models.SET_NULL)
to_user = models.ForeignKey(User, related_name="to_badges", on_delete=models.CASCADE)
post = models.ForeignKey(Post, related_name="post_badges", null=True, on_delete=models.SET_NULL)
comment = models.ForeignKey(Comment, related_name="comment_badges", null=True, on_delete=models.SET_NULL)

note = models.TextField(null=True)

class Meta:
db_table = "user_badges"
unique_together = [
("from_user", "to_user", "badge", "post_id"),
("from_user", "to_user", "badge", "comment_id"),
]

@classmethod
def create_user_badge(cls, badge, from_user, to_user, post=None, comment=None, note=None):
if from_user == to_user:
raise BadRequest(
title="🛑 Нельзя дарить награды самому себе",
message="Это что такое-то вообще!"
)

if badge.price_days >= from_user.membership_days_left():
raise InsufficientFunds(
title="💸 Недостаточно средств :(",
message=f"Вы не можете подарить юзеру эту награду, "
f"так как у вас осталось {math.floor(from_user.membership_days_left())} дней членства, "
f"а награда стоит {badge.price_days}. "
f"Продлите членство в настройках своего профиля."
)

with transaction.atomic():
# store user badge
try:
user_badge = UserBadge.objects.create(
badge=badge,
from_user=from_user,
to_user=to_user,
post=post,
comment=comment,
note=note,
)
except IntegrityError:
raise ContentDuplicated(
title="🛑 Вы уже дарили награду за этот пост или комментарий",
message="Повторно награды дарить нельзя. Но вы можете подарить другую награду."
)

# deduct days balance from profile
User.objects\
.filter(id=from_user.id)\
.update(
membership_expires_at=F("membership_expires_at") - timedelta(days=badge.price_days)
)

# add badge to post/comment metadata (just for caching)
comment_or_post = comment or post
metadata = comment_or_post.metadata or {}
badges = metadata.get("badges") or {}
if badge.code not in badges:
# add new badge
badges[badge.code] = {
"title": badge.title,
"description": badge.description,
"count": 1,
}
else:
# increment badge count for this post
badges[badge.code]["count"] += 1

# update only that metadata (do not use .save(), it saves all fields and can cause side-effects)
metadata["badges"] = badges
type(comment_or_post).objects.filter(id=comment_or_post.id).update(metadata=metadata)

return user_badge

@classmethod
def user_badges(cls, user):
return UserBadge.objects.filter(to_user=user).select_related("badge").order_by("-created_at")

@classmethod
def user_badges_grouped(cls, user):
badges = {
badge.code: badge for badge in Badge.visible_objects()
}

badge_groups = UserBadge.objects\
.filter(to_user=user)\
.order_by("badge_id")\
.values("badge_id")\
.annotate(count=Count("badge_id"))

return {
badge_group["badge_id"]: {
"title": badges[badge_group["badge_id"]].title,
"description": badges[badge_group["badge_id"]].description,
"count": badge_group["count"],
} for badge_group in badge_groups
}
90 changes: 90 additions & 0 deletions badges/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from django.conf import settings
from django.shortcuts import get_object_or_404, render

from auth.helpers import auth_required
from badges.models import Badge, UserBadge
from club.exceptions import BadRequest
from comments.models import Comment
from posts.models.post import Post


@auth_required
def create_badge_for_post(request, post_slug):
post = get_object_or_404(Post, slug=post_slug)
if post.deleted_at:
raise BadRequest(
title="😵 Пост удалён",
message="Нельзя давать награды за удалённые посты"
)

if request.method != "POST":
if request.me.membership_days_left() < settings.MIN_DAYS_TO_GIVE_BADGES:
return render(request, "badges/messages/insufficient_funds.html")

return render(request, "badges/create.html", {
"post": post,
"badges": Badge.visible_objects().all(),
})

badge_code = request.POST.get("badge_code")
badge = Badge.objects.filter(code=badge_code).first()
if not badge or not badge.is_visible:
raise BadRequest(
title="🙅‍♀️ Бейджик недоступен",
message="Данную награду пока нельзя выдавать"
)

note = (request.POST.get("note") or "")[:1000]
user_badge = UserBadge.create_user_badge(
badge=badge,
from_user=request.me,
to_user=post.author,
post=post,
note=note,
)

return render(request, "badges/messages/success.html", {
"user_badge": user_badge,
"show_funds_warning": request.me.membership_days_left() - user_badge.badge.price_days < settings.MIN_DAYS_TO_GIVE_BADGES,
})


@auth_required
def create_badge_for_comment(request, comment_id):
comment = get_object_or_404(Comment, id=comment_id)
if comment.is_deleted:
raise BadRequest(
title="😵 Комментарий удалён",
message="Нельзя выдавать награды за удалённые комменты"
)

if request.method != "POST":
if request.me.membership_days_left() < settings.MIN_DAYS_TO_GIVE_BADGES:
return render(request, "badges/messages/insufficient_funds.html")

return render(request, "badges/create.html", {
"comment": comment,
"badges": Badge.visible_objects().all(),
})

badge_code = request.POST.get("badge_code")
badge = Badge.objects.filter(code=badge_code).first()
if not badge or not badge.is_visible:
raise BadRequest(
title="🙅‍♀️ Бейджик недоступен",
message="Данную награду пока нельзя выдавать"
)

note = (request.POST.get("note") or "")[:1000]
user_badge = UserBadge.create_user_badge(
badge=badge,
from_user=request.me,
to_user=comment.author,
comment=comment,
note=note,
)

return render(request, "badges/messages/success.html", {
"user_badge": user_badge,
"show_funds_warning": request.me.membership_days_left() - user_badge.badge.price_days < settings.MIN_DAYS_TO_GIVE_BADGES,
})
11 changes: 11 additions & 0 deletions club/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ class ContentDuplicated(ClubException):
"Проверьте всё ли в порядке."


class InsufficientFunds(ClubException):
default_code = "insufficient-funds"
default_title = "Недостаточно средств"


class URLParsingException(ClubException):
default_code = "url-parser-exception"
default_title = "Не удалось распарсить URL"
Expand All @@ -53,6 +58,11 @@ class InvalidCode(ClubException):
default_message = "Введите или запросите его еще раз. Через несколько неправильных попыток коды удаляются"


class ApiInsufficientFunds(ClubException):
default_code = "api-insufficient-funds"
default_title = "Недостаточно средств"


class ApiException(ClubException):
default_message = None

Expand All @@ -65,3 +75,4 @@ class ApiAuthRequired(ApiException):
class ApiAccessDenied(ApiException):
default_code = "api-access-denied"
default_title = "Access Denied"

8 changes: 5 additions & 3 deletions club/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"notifications.apps.NotificationsConfig",
"search.apps.SearchConfig",
"gdpr.apps.GdprConfig",
# "badges.apps.BadgesConfig",
"badges.apps.BadgesConfig",
"simple_history",
"django_q",
"webpack_loader",
Expand Down Expand Up @@ -183,6 +183,7 @@
PEOPLE_PAGE_SIZE = 18
PROFILE_COMMENTS_PAGE_SIZE = 100
PROFILE_POSTS_PAGE_SIZE = 30
PROFILE_BADGES_PAGE_SIZE = 50

COMMUNITY_APPROVE_UPVOTES = 35

Expand Down Expand Up @@ -255,6 +256,8 @@
STRIPE_CANCEL_URL = APP_HOST + "/join/"
STRIPE_SUCCESS_URL = APP_HOST + "/monies/done/?reference={CHECKOUT_SESSION_ID}"

WEBHOOK_SECRETS = set(os.getenv("WEBHOOK_SECRETS", "").split(","))

COMMENT_EDITABLE_TIMEDELTA = timedelta(hours=24)
COMMENT_DELETABLE_TIMEDELTA = timedelta(days=10 * 365)
COMMENT_DELETABLE_BY_POST_AUTHOR_TIMEDELTA = timedelta(days=14)
Expand All @@ -266,6 +269,7 @@
POST_HOTNESS_PERIOD = timedelta(days=5) # time window for hotness recalculation script
MIN_FRIEND_COMMENT_LENGTH = 250 # notify comments only from a certain length
MAX_COMMENTS_FOR_DELETE_VS_CLEAR = 10 # number of comments after which the post cannot be deleted
MIN_DAYS_TO_GIVE_BADGES = 35 # minimum "days" balance to buy and gift any badge
CLEARED_POST_TEXT = "```\n" \
"😥 Этот пост был удален самим автором и от него остались лишь комментарии участников. " \
"Если вы хотите приютить и развить эту тему как новый автор, напишите модераторам Клуба: moderator@vas3k.club." \
Expand All @@ -278,8 +282,6 @@
CHATS_GUIDE_URL = "https://vas3k.club/post/9542/"
PEOPLE_GUIDE_URL = "https://vas3k.club/post/2584/"

WEBHOOK_SECRETS = set(os.getenv("WEBHOOK_SECRETS", "").split(","))

WEBPACK_LOADER = {
"DEFAULT": {
"CACHE": not DEBUG,
Expand Down
Loading

0 comments on commit 67e4d70

Please sign in to comment.