diff --git a/dspace/settings.py b/dspace/settings.py
index 478ba9f..bc2c17b 100644
--- a/dspace/settings.py
+++ b/dspace/settings.py
@@ -1,5 +1,6 @@
import os
from pathlib import Path
+
from django.core.exceptions import ImproperlyConfigured
from dotenv import load_dotenv
@@ -34,6 +35,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
+ 'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@@ -102,7 +104,9 @@ if not DEBUG:
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
MEDIA_URL = '/media/'
-MEDIA_ROOT = BASE_DIR / 'media'
+MEDIA_ROOT = Path(os.environ.get('DJANGO_MEDIA_ROOT', BASE_DIR / 'media'))
+# Отдавать media самим Django (для запуска в Docker без nginx); в бою media отдаёт nginx
+SERVE_MEDIA = os.environ.get('DJANGO_SERVE_MEDIA', 'False') == 'True'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
@@ -111,6 +115,9 @@ SMARTCAPTCHA_CLIENT_KEY = os.environ.get('SMARTCAPTCHA_CLIENT_KEY', '')
SMARTCAPTCHA_SERVER_KEY = os.environ.get('SMARTCAPTCHA_SERVER_KEY', '')
+# Автопостинг новых статей в Telegram-канал и группу VK (выключен по умолчанию)
+SOCIAL_AUTOPOST = os.environ.get('SOCIAL_AUTOPOST', 'False') == 'True'
+
# Telegram Bot Settings
TELEGRAM_BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN', '')
TELEGRAM_CHANNEL_ID = os.environ.get('TELEGRAM_CHANNEL_ID', '')
@@ -126,7 +133,7 @@ VK_GROUP_ID = os.environ.get('VK_GROUP_ID', '')
# Email Settings
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = os.environ.get('EMAIL_HOST', 'smtp.yandex.ru')
-EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 465))
+EMAIL_PORT = int(os.environ.get('EMAIL_PORT', '465'))
EMAIL_USE_SSL = True
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
@@ -187,4 +194,4 @@ LOGGING = {
}
# Create logs directory
-(BASE_DIR / 'logs').mkdir(exist_ok=True)
\ No newline at end of file
+(BASE_DIR / 'logs').mkdir(exist_ok=True)
diff --git a/dspace/urls.py b/dspace/urls.py
index a3bf7a7..a9a9ce1 100644
--- a/dspace/urls.py
+++ b/dspace/urls.py
@@ -1,8 +1,9 @@
-from django.contrib import admin
-from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
+from django.contrib import admin
from django.contrib.sitemaps.views import sitemap
+from django.urls import include, path
+
from main.sitemaps import ArticleSitemap, ProjectSitemap, StaticViewSitemap
sitemaps = {
@@ -17,9 +18,10 @@ urlpatterns = [
path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='sitemap'),
]
-if settings.DEBUG:
+if settings.DEBUG or settings.SERVE_MEDIA:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATICFILES_DIRS[0])
handler404 = 'main.views.handler404'
-handler500 = 'main.views.handler500'
\ No newline at end of file
+handler500 = 'main.views.handler500'
diff --git a/main/admin.py b/main/admin.py
index 11a63a7..a2fc78a 100644
--- a/main/admin.py
+++ b/main/admin.py
@@ -4,10 +4,24 @@ import json
from django.contrib import admin
from django.http import HttpResponse
from django.utils.html import format_html
+
from .models import (
- CustomUser, Category, Article, ArticleImage, ArticleFile, ArticleLink,
- Project, ProjectStatus, Skill, Comment, ArticleLike, CommentLike,
- ContactMessage, Experience, Education, SiteSettings
+ Article,
+ ArticleFile,
+ ArticleImage,
+ ArticleLike,
+ ArticleLink,
+ Category,
+ Comment,
+ CommentLike,
+ ContactMessage,
+ CustomUser,
+ Education,
+ Experience,
+ Project,
+ ProjectStatus,
+ SiteSettings,
+ Skill,
)
diff --git a/main/context_processors.py b/main/context_processors.py
index 20883f9..1817230 100644
--- a/main/context_processors.py
+++ b/main/context_processors.py
@@ -5,4 +5,4 @@ def site_settings(request):
"""Глобальный контекст с настройками сайта."""
return {
'global_settings': SiteSettings.load()
- }
\ No newline at end of file
+ }
diff --git a/main/forms.py b/main/forms.py
index b86cd02..602dbce 100644
--- a/main/forms.py
+++ b/main/forms.py
@@ -1,11 +1,11 @@
-from django import forms
-from django.contrib.auth.forms import AuthenticationForm
-from django.core.validators import RegexValidator
-from django.conf import settings
import bleach
import requests
+from django import forms
+from django.conf import settings
+from django.contrib.auth.forms import AuthenticationForm
+from django.core.validators import RegexValidator
-from .models import CustomUser, Comment, ContactMessage
+from .models import Comment, ContactMessage, CustomUser
class SmartCaptchaField(forms.CharField):
@@ -248,4 +248,4 @@ class ContactForm(forms.ModelForm):
def clean_message(self):
message = self.cleaned_data.get('message')
message = bleach.clean(message, tags=[], strip=True)
- return message
\ No newline at end of file
+ return message
diff --git a/main/management/commands/load_initial_data.py b/main/management/commands/load_initial_data.py
index 6e9344f..3b30dbe 100644
--- a/main/management/commands/load_initial_data.py
+++ b/main/management/commands/load_initial_data.py
@@ -1,9 +1,9 @@
-from django.core.management.base import BaseCommand
-from main.models import (
- Skill, Experience, Education, Project, Category, SiteSettings, Article
-)
from datetime import date
+from django.core.management.base import BaseCommand
+
+from main.models import Article, Category, Education, Experience, Project, SiteSettings, Skill
+
class Command(BaseCommand):
help = 'Загрузка начальных данных из резюме Деева Е.В.'
@@ -363,4 +363,4 @@ class Command(BaseCommand):
defaults=ach_data
)
- self.stdout.write(self.style.SUCCESS(f' OK ({len(achievements_data)} достижений)'))
\ No newline at end of file
+ self.stdout.write(self.style.SUCCESS(f' OK ({len(achievements_data)} достижений)'))
diff --git a/main/management/commands/populate_demo.py b/main/management/commands/populate_demo.py
index 5467b4e..830ca02 100644
--- a/main/management/commands/populate_demo.py
+++ b/main/management/commands/populate_demo.py
@@ -3,14 +3,20 @@
Использование: python manage.py populate_demo
"""
-from django.core.management.base import BaseCommand
+from datetime import date
+
from django.contrib.auth import get_user_model
+from django.core.management.base import BaseCommand
+
from main.models import (
- SiteSettings, Skill, Project, Article, ArticleCategory,
- Experience, Education, Achievement, Comment
+ Article,
+ ArticleCategory,
+ Education,
+ Experience,
+ Project,
+ SiteSettings,
+ Skill,
)
-from datetime import date, timedelta
-import random
User = get_user_model()
@@ -147,4 +153,4 @@ class Command(BaseCommand):
}
)
- self.stdout.write(self.style.SUCCESS('Демо-данные успешно созданы!'))
\ No newline at end of file
+ self.stdout.write(self.style.SUCCESS('Демо-данные успешно созданы!'))
diff --git a/main/models.py b/main/models.py
index 93ada4c..986271a 100644
--- a/main/models.py
+++ b/main/models.py
@@ -1,11 +1,29 @@
-from django.db import models
+import ipaddress
+import logging
+import socket
+from urllib.parse import urlparse
+
+import requests
+from bs4 import BeautifulSoup
from django.contrib.auth.models import AbstractUser
+from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from unidecode import unidecode
-import requests
-from bs4 import BeautifulSoup
-from urllib.parse import urlparse
+
+logger = logging.getLogger(__name__)
+
+
+def is_public_http_url(url):
+ """Проверка, что ссылка ведёт на публичный http(s)-адрес, а не во внутреннюю сеть сервера."""
+ parsed = urlparse(url)
+ if parsed.scheme not in ('http', 'https') or not parsed.hostname:
+ return False
+ try:
+ infos = socket.getaddrinfo(parsed.hostname, parsed.port or 443)
+ except OSError:
+ return False
+ return all(ipaddress.ip_address(info[4][0]).is_global for info in infos)
class CustomUser(AbstractUser):
@@ -235,6 +253,9 @@ class ArticleLink(models.Model):
def fetch_preview(self):
"""Автоматическое получение метаданных ссылки."""
+ if not is_public_http_url(self.url):
+ logger.warning('Превью не загружено: адрес %s не публичный', self.url)
+ return
try:
headers = {'User-Agent': 'Mozilla/5.0 (compatible; ArticleBot/1.0)'}
response = requests.get(self.url, headers=headers, timeout=10)
@@ -262,8 +283,8 @@ class ArticleLink(models.Model):
if og_image:
self.preview_image = og_image.get('content', '')
- except Exception:
- pass
+ except (requests.RequestException, AttributeError, TypeError) as e:
+ logger.warning('Не удалось получить превью %s: %s', self.url, e)
def save(self, *args, **kwargs):
if not self.title:
@@ -653,4 +674,4 @@ class SiteSettings(models.Model):
@classmethod
def load(cls):
obj, created = cls.objects.get_or_create(pk=1)
- return obj
\ No newline at end of file
+ return obj
diff --git a/main/signals.py b/main/signals.py
index 0831c0a..f9da6e7 100644
--- a/main/signals.py
+++ b/main/signals.py
@@ -1,79 +1,72 @@
+import html
+import logging
+
+import requests
+from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
-from django.conf import settings
-import requests
-import logging
from .models import Article
logger = logging.getLogger(__name__)
+SITE_URL = 'https://deev.space'
+
@receiver(post_save, sender=Article)
def publish_to_social_media(sender, instance, created, **kwargs):
"""Автоматическая публикация новой статьи в социальные сети."""
- if not created:
+ if not created or not instance.is_published or instance.is_achievement:
return
- if not instance.is_published:
- return
+ if settings.SOCIAL_AUTOPOST:
+ if settings.TELEGRAM_BOT_TOKEN and settings.TELEGRAM_CHANNEL_ID:
+ try:
+ publish_to_telegram(instance)
+ logger.info('Статья "%s" опубликована в Telegram', instance.title)
+ except requests.RequestException:
+ logger.exception('Ошибка публикации в Telegram')
- if instance.is_achievement:
- return
+ if settings.VK_ACCESS_TOKEN and settings.VK_GROUP_ID:
+ try:
+ publish_to_vk(instance)
+ logger.info('Статья "%s" опубликована в VK', instance.title)
+ except requests.RequestException:
+ logger.exception('Ошибка публикации в VK')
- '''
- # Telegram
- if settings.TELEGRAM_BOT_TOKEN and settings.TELEGRAM_CHANNEL_ID:
- try:
- publish_to_telegram(instance)
- logger.info(f'Статья "{instance.title}" опубликована в Telegram')
- except Exception as e:
- logger.error(f'Ошибка публикации в Telegram: {e}')
+ ping_search_engines()
- # VK
- if settings.VK_ACCESS_TOKEN and settings.VK_GROUP_ID:
- try:
- publish_to_vk(instance)
- logger.info(f'Статья "{instance.title}" опубликована в VK')
- except Exception as e:
- logger.error(f'Ошибка публикации в VK: {e}')
- '''
- try:
- ping_search_engines(instance)
- logger.info(f'Поисковые системы уведомлены о статье "{instance.title}"')
- except Exception as e:
- logger.error(f'Ошибка уведомления поисковых систем: {e}')
+def build_telegram_text(article):
+ """Текст поста для Telegram в режиме parse_mode=HTML."""
+ article_url = f'{SITE_URL}{article.get_absolute_url()}'
+ text = f'📝 {html.escape(article.title)}\n\n'
+ if article.excerpt:
+ excerpt = article.excerpt[:200] + '...' if len(article.excerpt) > 200 else article.excerpt
+ text += f'{html.escape(excerpt)}\n\n'
+ text += f'Читать полностью'
+ return text
def publish_to_telegram(article):
"""Публикация статьи в Telegram-канал."""
- bot_token = settings.TELEGRAM_BOT_TOKEN
- channel_id = settings.TELEGRAM_CHANNEL_ID
-
- article_url = f"https://deev.space{article.get_absolute_url()}"
-
- text = f"📝 *{escape_markdown(article.title)}*\n\n"
- if article.excerpt:
- excerpt = article.excerpt[:200] + '...' if len(article.excerpt) > 200 else article.excerpt
- text += f"{escape_markdown(excerpt)}\n\n"
- text += f"[Читать полностью]({article_url})"
+ api = f'https://api.telegram.org/bot{settings.TELEGRAM_BOT_TOKEN}'
+ text = build_telegram_text(article)
if article.img:
- url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
+ url = f'{api}/sendPhoto'
data = {
- 'chat_id': channel_id,
- 'photo': f"https://deev.space{article.img.url}",
+ 'chat_id': settings.TELEGRAM_CHANNEL_ID,
+ 'photo': f'{SITE_URL}{article.img.url}',
'caption': text,
- 'parse_mode': 'Markdown'
+ 'parse_mode': 'HTML',
}
else:
- url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
+ url = f'{api}/sendMessage'
data = {
- 'chat_id': channel_id,
+ 'chat_id': settings.TELEGRAM_CHANNEL_ID,
'text': text,
- 'parse_mode': 'Markdown',
- 'disable_web_page_preview': False
+ 'parse_mode': 'HTML',
}
response = requests.post(url, data=data, timeout=10)
@@ -82,55 +75,31 @@ def publish_to_telegram(article):
def publish_to_vk(article):
"""Публикация статьи в группу VKontakte."""
- access_token = settings.VK_ACCESS_TOKEN
- group_id = settings.VK_GROUP_ID
+ article_url = f'{SITE_URL}{article.get_absolute_url()}'
- article_url = f"https://deev.space{article.get_absolute_url()}"
-
- message = f"📝 {article.title}\n\n"
+ message = f'📝 {article.title}\n\n'
if article.excerpt:
excerpt = article.excerpt[:300] + '...' if len(article.excerpt) > 300 else article.excerpt
- message += f"{excerpt}\n\n"
- message += f"🔗 Читать: {article_url}"
+ message += f'{excerpt}\n\n'
+ message += f'🔗 Читать: {article_url}'
- url = "https://api.vk.com/method/wall.post"
params = {
- 'owner_id': f'-{group_id}',
+ 'owner_id': f'-{settings.VK_GROUP_ID}',
'message': message,
'from_group': 1,
- 'access_token': access_token,
- 'v': '5.131'
+ 'access_token': settings.VK_ACCESS_TOKEN,
+ 'v': '5.131',
}
-
if article.img:
- params['attachments'] = f"https://deev.space{article.img.url}"
+ params['attachments'] = f'{SITE_URL}{article.img.url}'
- response = requests.post(url, params=params, timeout=10)
+ response = requests.post('https://api.vk.com/method/wall.post', params=params, timeout=10)
response.raise_for_status()
-def escape_markdown(text):
- """Экранирование специальных символов Markdown."""
- escape_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']
- for char in escape_chars:
- text = text.replace(char, f'\\{char}')
- return text
-
-
-def ping_search_engines(article):
- """Уведомление поисковых систем о новой статье."""
- article_url = f"https://deev.space{article.get_absolute_url()}"
-
- # Google
+def ping_search_engines():
+ """Уведомление Яндекса об обновлении sitemap (Google ping-эндпоинт отключил в 2023 году)."""
try:
- google_ping_url = f"https://www.google.com/ping?sitemap=https://deev.space/sitemap.xml"
- requests.get(google_ping_url, timeout=5)
- except Exception as e:
- logger.error(f'Ошибка ping Google: {e}')
-
- # Яндекс
- try:
- yandex_ping_url = f"https://webmaster.yandex.ru/ping?sitemap=https://deev.space/sitemap.xml"
- requests.get(yandex_ping_url, timeout=5)
- except Exception as e:
- logger.error(f'Ошибка ping Яндекс: {e}')
\ No newline at end of file
+ requests.get(f'https://webmaster.yandex.ru/ping?sitemap={SITE_URL}/sitemap.xml', timeout=5)
+ except requests.RequestException as e:
+ logger.warning('Ошибка ping Яндекса: %s', e)
diff --git a/main/sitemaps.py b/main/sitemaps.py
index 6b497ee..2f38b93 100644
--- a/main/sitemaps.py
+++ b/main/sitemaps.py
@@ -1,5 +1,6 @@
from django.contrib.sitemaps import Sitemap
from django.urls import reverse
+
from .models import Article, Project
@@ -36,4 +37,4 @@ class StaticViewSitemap(Sitemap):
return ['index', 'about', 'projects', 'blog', 'achievements', 'contacts']
def location(self, item):
- return reverse(item)
\ No newline at end of file
+ return reverse(item)
diff --git a/main/templatetags/custom_filters.py b/main/templatetags/custom_filters.py
index 91b7938..85377ea 100644
--- a/main/templatetags/custom_filters.py
+++ b/main/templatetags/custom_filters.py
@@ -1,6 +1,7 @@
+import math
+
from django import template
-from django.utils.safestring import mark_safe
-import re, math
+from django.utils.html import format_html
register = template.Library()
@@ -113,8 +114,7 @@ def status_label(status):
def render_tech_badge(tech_name):
"""Рендерит HTML для бейджа технологии."""
icon_class = tech_icon(tech_name)
- html = f' {tech_name}'
- return mark_safe(html)
+ return format_html(' {}', icon_class, tech_name)
@register.filter
diff --git a/main/tests.py b/main/tests.py
index 7ce503c..4929020 100644
--- a/main/tests.py
+++ b/main/tests.py
@@ -1,3 +1,2 @@
-from django.test import TestCase
# Create your tests here.
diff --git a/main/urls.py b/main/urls.py
index d216d67..acd24d0 100644
--- a/main/urls.py
+++ b/main/urls.py
@@ -1,4 +1,5 @@
from django.urls import path
+
from . import views
urlpatterns = [
@@ -23,4 +24,4 @@ urlpatterns = [
path('api/article//comment/', views.add_comment, name='add_comment'),
path('api/article//like/', views.toggle_article_like, name='toggle_article_like'),
path('api/comment//like/', views.toggle_comment_like, name='toggle_comment_like'),
-]
\ No newline at end of file
+]
diff --git a/main/views.py b/main/views.py
index 40efcf5..b7c7cd7 100644
--- a/main/views.py
+++ b/main/views.py
@@ -1,30 +1,38 @@
-from django.shortcuts import render, get_object_or_404, redirect
-from django.http import JsonResponse
-from django.views.generic import ListView, DetailView, TemplateView
-from django.contrib.auth import login, authenticate, logout
-from django.contrib.auth.decorators import login_required
-from django.contrib import messages
-from django.core.paginator import Paginator
-from django.core.mail import send_mail
-from django.conf import settings
-from django.db.models import Q, Count
-from django.views.decorators.http import require_POST, require_GET
-from django.utils import timezone
-from django.utils.html import escape
-from django.utils.decorators import method_decorator
-from django.views.decorators.csrf import ensure_csrf_cookie
-from django.db.models import Sum
-from datetime import timedelta
-from .middleware import client_ip_hash, is_bot
import json
import logging
-import requests
+from datetime import timedelta
+import requests
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth import login, logout
+from django.contrib.auth.decorators import login_required
+from django.core.mail import send_mail
+from django.db.models import Count, Q, Sum
+from django.http import JsonResponse
+from django.shortcuts import get_object_or_404, redirect, render
+from django.utils import timezone
+from django.utils.decorators import method_decorator
+from django.utils.html import escape
+from django.views.decorators.csrf import ensure_csrf_cookie
+from django.views.decorators.http import require_POST
+from django.views.generic import DetailView, ListView, TemplateView
+
+from .forms import CommentForm, ContactForm, LoginForm, RegisterForm
+from .middleware import client_ip_hash, is_bot
from .models import (
- Article, Project, ProjectStatus, Skill, Comment, ArticleLike, CommentLike, ArticleView,
- ContactMessage, Experience, Education, Category, SiteSettings
+ Article,
+ ArticleLike,
+ ArticleView,
+ Category,
+ Comment,
+ CommentLike,
+ Education,
+ Experience,
+ Project,
+ SiteSettings,
+ Skill,
)
-from .forms import RegisterForm, LoginForm, CommentForm, ContactForm
logger = logging.getLogger(__name__)
@@ -398,10 +406,9 @@ class ContactsView(TemplateView):
message=f'От: {message.name} ({message.email})\n\nТема: {message.subject}\n\n{message.message}',
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[settings.CONTACT_EMAIL],
- fail_silently=True,
)
- except Exception as e:
- logger.error(f'Ошибка отправки email: {e}')
+ except Exception:
+ logger.exception('Ошибка отправки email')
try:
notify_alertbot_contact_message(message)
@@ -595,4 +602,4 @@ def handler404(request, exception):
def handler500(request):
"""Обработчик ошибки 500."""
- return render(request, 'errors/500.html', status=500)
\ No newline at end of file
+ return render(request, 'errors/500.html', status=500)