Merge branch 'bruno' of https://git.vontor.cz/Brunobrno/vontor-cz into bruno
This commit is contained in:
@@ -14,8 +14,10 @@ def send_contact_me_email_task(client_email, message_content):
|
||||
"client_email": client_email,
|
||||
"message_content": message_content
|
||||
}
|
||||
config_email = SiteConfiguration.get_solo().contact_email
|
||||
recipient = config_email if config_email else "brunovontor@gmail.com"
|
||||
send_email_with_context(
|
||||
recipients=SiteConfiguration.get_solo().contact_email,
|
||||
recipients=recipient,
|
||||
subject="Poptávka z kontaktního formuláře!!!",
|
||||
template_path="email/contact_me.html",
|
||||
context=context,
|
||||
|
||||
@@ -6,3 +6,6 @@ class ChatConfig(AppConfig):
|
||||
name = 'social.chat'
|
||||
|
||||
label = "chat"
|
||||
|
||||
def ready(self):
|
||||
import social.chat.signals # noqa: F401
|
||||
|
||||
@@ -9,7 +9,11 @@ class IsChatMember(IsAuthenticated):
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return request.user.is_superuser or obj.members.filter(pk=request.user.pk).exists()
|
||||
return (
|
||||
request.user.is_superuser
|
||||
or obj.owner == request.user
|
||||
or obj.members.filter(pk=request.user.pk).exists()
|
||||
)
|
||||
|
||||
|
||||
class CanManageChat(IsAuthenticated):
|
||||
|
||||
@@ -43,11 +43,12 @@ class MessageHistorySerializer(serializers.ModelSerializer):
|
||||
|
||||
class ReplyToSerializer(serializers.ModelSerializer):
|
||||
sender = MessageSenderSerializer(read_only=True)
|
||||
media_files = MessageFileSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Message
|
||||
fields = ['id', 'content', 'sender']
|
||||
read_only_fields = ['id', 'content', 'sender']
|
||||
fields = ['id', 'content', 'sender', 'created_at', 'media_files']
|
||||
read_only_fields = ['id', 'content', 'sender', 'created_at', 'media_files']
|
||||
|
||||
|
||||
class MessageSerializer(serializers.ModelSerializer):
|
||||
@@ -69,23 +70,34 @@ class MessageSerializer(serializers.ModelSerializer):
|
||||
if not reply_to_id:
|
||||
return None
|
||||
try:
|
||||
msg = Message.all_objects.select_related('sender').get(pk=reply_to_id)
|
||||
msg = Message.all_objects.select_related('sender').prefetch_related('media_files').get(pk=reply_to_id)
|
||||
except Message.DoesNotExist:
|
||||
return None
|
||||
|
||||
from django.conf import settings
|
||||
sender_data = None
|
||||
if msg.sender:
|
||||
from django.conf import settings
|
||||
avatar = (settings.MEDIA_URL + msg.sender.avatar.name) if msg.sender.avatar else None
|
||||
sender_data = {'id': msg.sender.id, 'username': msg.sender.username, 'avatar': avatar}
|
||||
else:
|
||||
sender_data = {'id': 0, 'username': '…', 'avatar': None}
|
||||
|
||||
media_files_data = []
|
||||
if not msg.is_deleted:
|
||||
for f in msg.media_files.all():
|
||||
media_files_data.append({
|
||||
'id': f.id,
|
||||
'file': settings.MEDIA_URL + f.file.name if f.file else '',
|
||||
'media_type': f.media_type,
|
||||
'uploaded_at': f.uploaded_at.isoformat(),
|
||||
})
|
||||
|
||||
return {
|
||||
'id': msg.id,
|
||||
# content=None signals the frontend to show the deleted tombstone
|
||||
'content': None if msg.is_deleted else msg.content,
|
||||
'sender': sender_data,
|
||||
'created_at': msg.created_at.isoformat(),
|
||||
'media_files': media_files_data,
|
||||
}
|
||||
|
||||
class Meta:
|
||||
|
||||
35
backend/social/chat/signals.py
Normal file
35
backend/social/chat/signals.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
|
||||
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
|
||||
def sync_dm_chat_identity(sender, instance, created, update_fields, **kwargs):
|
||||
"""Keep DM chat name/icon in sync when a user updates their username or avatar."""
|
||||
if created:
|
||||
return
|
||||
|
||||
changed = set(update_fields) if update_fields else None # None = full save
|
||||
username_changed = changed is None or 'username' in changed
|
||||
avatar_changed = changed is None or 'avatar' in changed
|
||||
|
||||
if not (username_changed or avatar_changed):
|
||||
return
|
||||
|
||||
from .models import Chat
|
||||
|
||||
dm_chats = Chat.objects.filter(
|
||||
chat_type=Chat.ChatType.DM,
|
||||
members=instance,
|
||||
).exclude(owner=instance)
|
||||
|
||||
if not dm_chats.exists():
|
||||
return
|
||||
|
||||
update_kwargs = {}
|
||||
if username_changed:
|
||||
update_kwargs['name'] = instance.username
|
||||
if avatar_changed:
|
||||
update_kwargs['icon'] = instance.avatar.name if instance.avatar else None
|
||||
|
||||
dm_chats.update(**update_kwargs)
|
||||
@@ -53,7 +53,23 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
return Chat.objects.filter(Q(members=user) | Q(owner=user)).distinct()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(owner=self.request.user)
|
||||
chat = serializer.save(owner=self.request.user)
|
||||
|
||||
# Ensure the creator is always a member so they pass membership checks.
|
||||
chat.members.add(self.request.user)
|
||||
|
||||
if chat.chat_type == Chat.ChatType.DM:
|
||||
other = chat.members.exclude(pk=self.request.user.pk).first()
|
||||
if other:
|
||||
update_fields = []
|
||||
if not chat.name:
|
||||
chat.name = other.username
|
||||
update_fields.append('name')
|
||||
if not chat.icon and other.avatar:
|
||||
chat.icon = other.avatar
|
||||
update_fields.append('icon')
|
||||
if update_fields:
|
||||
chat.save(update_fields=update_fields)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Member management
|
||||
@@ -180,8 +196,7 @@ class MessageViewSet(viewsets.ModelViewSet):
|
||||
qs = Message.objects.select_related('sender', 'chat').prefetch_related('media_files', 'reactions')
|
||||
if user.is_superuser:
|
||||
return qs
|
||||
# Only messages from chats the user is a member of
|
||||
return qs.filter(chat__members=user)
|
||||
return qs.filter(Q(chat__members=user) | Q(chat__owner=user)).distinct()
|
||||
|
||||
def perform_update(self, serializer):
|
||||
message = serializer.instance
|
||||
@@ -221,7 +236,7 @@ class MessageViewSet(viewsets.ModelViewSet):
|
||||
ser.is_valid(raise_exception=True)
|
||||
|
||||
chat = ser.validated_data['chat']
|
||||
if not request.user.is_superuser and not chat.members.filter(pk=request.user.pk).exists():
|
||||
if not request.user.is_superuser and not chat.members.filter(pk=request.user.pk).exists() and chat.owner != request.user:
|
||||
raise PermissionDenied('You are not a member of this chat.')
|
||||
|
||||
message = Message.objects.create(
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import HubViewSet, HubPermissionViewSet, TagsViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('', HubViewSet, basename='hub')
|
||||
router.register('moderators', HubPermissionViewSet, basename='hub-moderator')
|
||||
router.register('tags', TagsViewSet, basename='hub-tag')
|
||||
hub_router = DefaultRouter()
|
||||
hub_router.register('', HubViewSet, basename='hub')
|
||||
|
||||
urlpatterns = router.urls
|
||||
moderators_router = DefaultRouter()
|
||||
moderators_router.register('', HubPermissionViewSet, basename='hub-moderator')
|
||||
|
||||
tags_router = DefaultRouter()
|
||||
tags_router.register('', TagsViewSet, basename='hub-tag')
|
||||
|
||||
# moderators/ and tags/ must be declared BEFORE the hub router urls so that
|
||||
# Django resolves them before the hub's generic /{pk}/ pattern can swallow them.
|
||||
urlpatterns = [
|
||||
path('moderators/', include(moderators_router.urls)),
|
||||
path('tags/', include(tags_router.urls)),
|
||||
] + hub_router.urls
|
||||
|
||||
@@ -58,6 +58,7 @@ from .serializers import HubPermissionSerializer, HubSerializer, TagsSerializer,
|
||||
class HubViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = HubSerializer
|
||||
permission_classes = [CanEditHub]
|
||||
lookup_field = 'name'
|
||||
filterset_fields = ['is_public', 'owner']
|
||||
search_fields = ['name', 'description']
|
||||
ordering_fields = ['name']
|
||||
@@ -223,7 +224,11 @@ class HubPermissionViewSet(viewsets.ModelViewSet):
|
||||
filterset_fields = ['user', 'changing_name', 'changing_description', 'changing_icon', 'changing_banner', 'managing_members', 'managing_posts', 'managing_chats']
|
||||
|
||||
def _get_hub(self):
|
||||
hub_id = self.kwargs.get('hub_pk') or self.request.query_params.get('hub')
|
||||
hub_id = (
|
||||
self.kwargs.get('hub_pk')
|
||||
or self.request.query_params.get('hub')
|
||||
or self.request.data.get('hub')
|
||||
)
|
||||
return Hub.objects.get(pk=hub_id)
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -279,10 +284,16 @@ class TagsViewSet(viewsets.ModelViewSet):
|
||||
ordering = ['name']
|
||||
|
||||
def _get_hub(self):
|
||||
hub_id = self.kwargs.get('hub_pk') or self.request.query_params.get('hub')
|
||||
hub_id = (
|
||||
self.kwargs.get('hub_pk')
|
||||
or self.request.query_params.get('hub')
|
||||
or self.request.data.get('hub')
|
||||
)
|
||||
return Hub.objects.get(pk=hub_id)
|
||||
|
||||
def get_queryset(self):
|
||||
if self.kwargs.get('pk'):
|
||||
return Tags.objects.all()
|
||||
return Tags.objects.filter(hub=self._get_hub())
|
||||
|
||||
def perform_create(self, serializer):
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model
|
||||
from rest_framework import serializers
|
||||
from .models import Post, PostContent, PostVote, PostSave
|
||||
from social.hubs.serializers import TagsSerializer
|
||||
from social.hubs.models import Hub
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -21,10 +22,17 @@ class PostContentSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ['mime_type']
|
||||
|
||||
|
||||
class PostHubSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Hub
|
||||
fields = ['id', 'name', 'icon']
|
||||
|
||||
|
||||
class PostSerializer(serializers.ModelSerializer):
|
||||
contents = PostContentSerializer(many=True, read_only=True)
|
||||
tags = TagsSerializer(many=True, read_only=True)
|
||||
author_detail = AuthorMinimalSerializer(source='author', read_only=True)
|
||||
hub_detail = PostHubSerializer(source='hub', read_only=True)
|
||||
vote_score = serializers.SerializerMethodField()
|
||||
user_vote = serializers.SerializerMethodField()
|
||||
reply_count = serializers.IntegerField(read_only=True, default=0)
|
||||
@@ -36,7 +44,7 @@ class PostSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
'id', 'content', 'created_at', 'updated_at',
|
||||
'author', 'author_detail',
|
||||
'hub', 'reply_to',
|
||||
'hub', 'hub_detail', 'reply_to',
|
||||
'tags', 'contents',
|
||||
'vote_score', 'user_vote', 'reply_count', 'is_saved', 'save_count',
|
||||
]
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
from django.db.models import Count, Q
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models import Count, Q, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
@@ -8,7 +12,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiPara
|
||||
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from social.hubs.models import Tags
|
||||
from vontor_cz.pagination import CreatedCursorPagination
|
||||
from vontor_cz.pagination import CreatedCursorPagination, TopPostsCursorPagination
|
||||
from .models import Post, PostContent, PostVote, PostSave
|
||||
from .permissions import CanDeletePost, IsPostAuthorOnly
|
||||
from .serializers import PostSerializer, PostContentSerializer, PostVoteSerializer, TagAttachSerializer
|
||||
@@ -78,6 +82,51 @@ class PostViewSet(viewsets.ModelViewSet):
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(author=self.request.user)
|
||||
|
||||
_TIME_WINDOWS = {
|
||||
'1h': timedelta(hours=1),
|
||||
'6h': timedelta(hours=6),
|
||||
'day': timedelta(days=1),
|
||||
'week': timedelta(weeks=1),
|
||||
'month': timedelta(days=30),
|
||||
'year': timedelta(days=365),
|
||||
}
|
||||
|
||||
def _get_cutoff(self, time_param):
|
||||
"""Return a datetime cutoff for the given time window, or None for 'all'."""
|
||||
if time_param in self._TIME_WINDOWS:
|
||||
return timezone.now() - self._TIME_WINDOWS[time_param]
|
||||
return None
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
sort = request.query_params.get('sort', 'newest')
|
||||
time_param = request.query_params.get('time', 'all')
|
||||
|
||||
qs = self.filter_queryset(self.get_queryset())
|
||||
|
||||
# Time filter
|
||||
if time_param == 'custom':
|
||||
start = request.query_params.get('start')
|
||||
end = request.query_params.get('end')
|
||||
if start:
|
||||
qs = qs.filter(created_at__date__gte=start)
|
||||
if end:
|
||||
qs = qs.filter(created_at__date__lte=end)
|
||||
else:
|
||||
cutoff = self._get_cutoff(time_param)
|
||||
if cutoff:
|
||||
qs = qs.filter(created_at__gte=cutoff)
|
||||
|
||||
if sort == 'top':
|
||||
qs = qs.annotate(vote_score=Coalesce(Sum('votes__vote'), 0)).order_by('-vote_score', '-id')
|
||||
paginator = TopPostsCursorPagination()
|
||||
else:
|
||||
qs = qs.order_by('-created_at')
|
||||
paginator = CreatedCursorPagination()
|
||||
|
||||
page = paginator.paginate_queryset(qs, request, view=self)
|
||||
ser = PostSerializer(page, many=True, context={'request': request})
|
||||
return paginator.get_paginated_response(ser.data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Media upload action
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
45
backend/templates/email/contact_me.html
Normal file
45
backend/templates/email/contact_me.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!-- Contact form submission notification -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0 20px;">
|
||||
<h2 style="margin: 0 0 6px; font-size: 20px; color: #031D44;">
|
||||
📬 Nová zpráva z kontaktního formuláře
|
||||
</h2>
|
||||
<p style="margin: 0; font-size: 13px; color: #666;">
|
||||
Přišla poptávka přes vontor.cz
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Sender email -->
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; background: #f7f9fc; border-left: 4px solid #70A288; border-radius: 4px; margin-bottom: 12px;">
|
||||
<p style="margin: 0 0 2px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #888;">Od</p>
|
||||
<p style="margin: 0; font-size: 15px; font-weight: 600; color: #031D44;">
|
||||
<a href="mailto:{{ client_email }}" style="color: #24719f; text-decoration: none;">{{ client_email }}</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr><td style="height: 12px;"></td></tr>
|
||||
|
||||
<!-- Message body -->
|
||||
<tr>
|
||||
<td style="padding: 16px; background: #f0f4f8; border-radius: 6px;">
|
||||
<p style="margin: 0 0 8px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #888;">Zpráva</p>
|
||||
<p style="margin: 0; font-size: 14px; line-height: 1.7; color: #222; white-space: pre-wrap;">{{ message_content }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr><td style="height: 20px;"></td></tr>
|
||||
|
||||
<!-- Reply button -->
|
||||
<tr>
|
||||
<td>
|
||||
<a href="mailto:{{ client_email }}"
|
||||
style="display: inline-block; padding: 10px 24px; background: #70A288; color: #ffffff; font-weight: 600; font-size: 14px; border-radius: 6px; text-decoration: none;">
|
||||
Odpovědět
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -18,6 +18,15 @@ class CreatedCursorPagination(CursorPagination):
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class TopPostsCursorPagination(CursorPagination):
|
||||
"""Cursor pagination ordered by vote score descending, then by id descending as tiebreaker."""
|
||||
page_size = 20
|
||||
ordering = ('-vote_score', '-id')
|
||||
cursor_query_param = 'cursor'
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class CreatedAscCursorPagination(CursorPagination):
|
||||
"""Cursor pagination ordered by `created_at` (oldest first).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user