diff --git a/.hgtags b/.hgtags
--- a/.hgtags
+++ b/.hgtags
@@ -20,3 +20,8 @@ 4d998aba79e4abf0a2e78e93baaa2c2800b1c49c
07fdef4ac33a859250d03f17c594089792bca615 2.2.1
bcc74d45f060ecd3ff06ff448165aea0d026cb3e 2.2.2
b0e629ff24eb47a449ecfb455dc6cc600d18c9e2 2.2.3
+1b52ba60f17fd7c90912c14d9d17e880b7952d01 2.2.4
+957e2fec91468f739b0fc2b9936d564505048c68 2.3.0
+bb91141c6ea5c822ccbe2d46c3c48bdab683b77d 2.4.0
+97eb184637e5691b288eaf6b03e8971f3364c239 2.5.0
+119fafc5381b933bf30d97be0b278349f6135075 2.5.1
diff --git a/boards/abstracts/settingsmanager.py b/boards/abstracts/settingsmanager.py
--- a/boards/abstracts/settingsmanager.py
+++ b/boards/abstracts/settingsmanager.py
@@ -12,19 +12,12 @@ SETTING_THEME = 'theme'
SETTING_FAVORITE_TAGS = 'favorite_tags'
SETTING_HIDDEN_TAGS = 'hidden_tags'
SETTING_PERMISSIONS = 'permissions'
+SETTING_USERNAME = 'username'
+SETTING_LAST_NOTIFICATION_ID = 'last_notification'
DEFAULT_THEME = 'md'
-def get_settings_manager(request):
- """
- Get settings manager based on the request object. Currently only
- session-based manager is supported. In the future, cookie-based or
- database-based managers could be implemented.
- """
- return SessionSettingsManager(request.session)
-
-
class SettingsManager:
"""
Base settings manager class. get_setting and set_setting methods should
@@ -77,9 +70,7 @@ class SettingsManager:
tag_names = self.get_setting(SETTING_FAVORITE_TAGS)
tags = []
if tag_names:
- for tag_name in tag_names:
- tag = get_object_or_404(Tag, name=tag_name)
- tags.append(tag)
+ tags = Tag.objects.filter(name__in=tag_names)
return tags
def add_fav_tag(self, tag):
@@ -145,3 +136,11 @@ class SessionSettingsManager(SettingsMan
def set_setting(self, setting, value):
self.session[setting] = value
+
+def get_settings_manager(request) -> SettingsManager:
+ """
+ Get settings manager based on the request object. Currently only
+ session-based manager is supported. In the future, cookie-based or
+ database-based managers could be implemented.
+ """
+ return SessionSettingsManager(request.session)
diff --git a/boards/admin.py b/boards/admin.py
--- a/boards/admin.py
+++ b/boards/admin.py
@@ -1,15 +1,27 @@
from django.contrib import admin
from boards.models import Post, Tag, Ban, Thread, KeyPair
+from django.utils.translation import ugettext_lazy as _
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'text')
- list_filter = ('pub_time', 'thread_new')
+ list_filter = ('pub_time',)
search_fields = ('id', 'title', 'text')
exclude = ('referenced_posts', 'refmap')
- readonly_fields = ('poster_ip', 'thread_new')
+ readonly_fields = ('poster_ip', 'threads', 'thread', 'images')
+
+ def ban_poster(self, request, queryset):
+ bans = 0
+ for post in queryset:
+ poster_ip = post.poster_ip
+ ban, created = Ban.objects.get_or_create(ip=poster_ip)
+ if created:
+ bans += 1
+ self.message_user(request, _('{} posters were banned').format(bans))
+
+ actions = ['ban_poster']
@admin.register(Tag)
diff --git a/boards/context_processors.py b/boards/context_processors.py
--- a/boards/context_processors.py
+++ b/boards/context_processors.py
@@ -1,4 +1,6 @@
-from boards.abstracts.settingsmanager import get_settings_manager
+from boards.abstracts.settingsmanager import get_settings_manager, \
+ SETTING_USERNAME, SETTING_LAST_NOTIFICATION_ID
+from boards.models.user import Notification
__author__ = 'neko259'
@@ -13,10 +15,34 @@ CONTEXT_THEME = 'theme'
CONTEXT_PPD = 'posts_per_day'
CONTEXT_TAGS = 'tags'
CONTEXT_USER = 'user'
+CONTEXT_NEW_NOTIFICATIONS_COUNT = 'new_notifications_count'
+CONTEXT_USERNAME = 'username'
PERMISSION_MODERATE = 'moderation'
+def get_notifications(context, request):
+ settings_manager = get_settings_manager(request)
+ username = settings_manager.get_setting(SETTING_USERNAME)
+ new_notifications_count = 0
+ if username is not None and len(username) > 0:
+ last_notification_id = settings_manager.get_setting(
+ SETTING_LAST_NOTIFICATION_ID)
+
+ new_notifications_count = Notification.objects.get_notification_posts(
+ username=username, last=last_notification_id).count()
+ context[CONTEXT_NEW_NOTIFICATIONS_COUNT] = new_notifications_count
+ context[CONTEXT_USERNAME] = username
+
+
+def get_moderator_permissions(context, request):
+ try:
+ moderate = request.user.has_perm(PERMISSION_MODERATE)
+ except AttributeError:
+ moderate = False
+ context[CONTEXT_MODERATOR] = moderate
+
+
def user_and_ui_processor(request):
context = dict()
@@ -29,13 +55,11 @@ def user_and_ui_processor(request):
context[CONTEXT_THEME_CSS] = 'css/' + theme + '/base_page.css'
# This shows the moderator panel
- try:
- moderate = request.user.has_perm(PERMISSION_MODERATE)
- except AttributeError:
- moderate = False
- context[CONTEXT_MODERATOR] = moderate
+ get_moderator_permissions(context, request)
context[CONTEXT_VERSION] = settings.VERSION
context[CONTEXT_SITE_NAME] = settings.SITE_NAME
+ get_notifications(context, request)
+
return context
diff --git a/boards/default_settings.py b/boards/default_settings.py
new file mode 100644
--- /dev/null
+++ b/boards/default_settings.py
@@ -0,0 +1,22 @@
+VERSION = '2.5.1 Yasako'
+SITE_NAME = 'Neboard'
+
+CACHE_TIMEOUT = 600 # Timeout for caching, if cache is used
+LOGIN_TIMEOUT = 3600 # Timeout between login tries
+MAX_TEXT_LENGTH = 30000 # Max post length in characters
+MAX_IMAGE_SIZE = 8 * 1024 * 1024 # Max image size
+
+# Thread bumplimit
+MAX_POSTS_PER_THREAD = 10
+# Old posts will be archived or deleted if this value is reached
+MAX_THREAD_COUNT = 5
+THREADS_PER_PAGE = 3
+DEFAULT_THEME = 'md'
+LAST_REPLIES_COUNT = 3
+
+# Enable archiving threads instead of deletion when the thread limit is reached
+ARCHIVE_THREADS = True
+# Limit posting speed
+LIMIT_POSTING_SPEED = False
+# Thread update
+WEBSOCKETS_ENABLED = True
diff --git a/boards/forms.py b/boards/forms.py
--- a/boards/forms.py
+++ b/boards/forms.py
@@ -1,29 +1,38 @@
import re
import time
-import hashlib
from django import forms
+from django.core.files.uploadedfile import SimpleUploadedFile
from django.forms.util import ErrorList
from django.utils.translation import ugettext_lazy as _
+import requests
from boards.mdx_neboard import formatters
from boards.models.post import TITLE_MAX_LENGTH
-from boards.models import PostImage, Tag
+from boards.models import Tag
from neboard import settings
-from boards import utils
import boards.settings as board_settings
+
+CONTENT_TYPE_IMAGE = (
+ 'image/jpeg',
+ 'image/png',
+ 'image/gif',
+ 'image/bmp',
+)
+
+REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
+
VETERAN_POSTING_DELAY = 5
ATTRIBUTE_PLACEHOLDER = 'placeholder'
+ATTRIBUTE_ROWS = 'rows'
LAST_POST_TIME = 'last_post_time'
LAST_LOGIN_TIME = 'last_login_time'
-TEXT_PLACEHOLDER = _('''Type message here. Use formatting panel for more advanced usage.''')
+TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
TAGS_PLACEHOLDER = _('tag1 several_words_tag')
-ERROR_IMAGE_DUPLICATE = _('Such image was already posted')
-
LABEL_TITLE = _('Title')
LABEL_TEXT = _('Text')
LABEL_TAG = _('Tag')
@@ -31,10 +40,19 @@ LABEL_SEARCH = _('Search')
TAG_MAX_LENGTH = 20
-REGEX_TAG = r'^[\w\d]+$'
+IMAGE_DOWNLOAD_CHUNK_BYTES = 100000
+
+HTTP_RESULT_OK = 200
+
+TEXTAREA_ROWS = 4
class FormatPanel(forms.Textarea):
+ """
+ Panel for text formatting. Consists of buttons to add different tags to the
+ form text area.
+ """
+
def render(self, name, value, attrs=None):
output = '
'
for formatter in formatters:
@@ -59,6 +77,9 @@ class PlainErrorList(ErrorList):
class NeboardForm(forms.Form):
+ """
+ Form with neboard-specific formatting.
+ """
def as_div(self):
"""
@@ -102,11 +123,18 @@ class PostForm(NeboardForm):
title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
label=LABEL_TITLE)
text = forms.CharField(
- widget=FormatPanel(attrs={ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER}),
+ widget=FormatPanel(attrs={
+ ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
+ ATTRIBUTE_ROWS: TEXTAREA_ROWS,
+ }),
required=False, label=LABEL_TEXT)
image = forms.ImageField(required=False, label=_('Image'),
widget=forms.ClearableFileInput(
attrs={'accept': 'image/*'}))
+ image_url = forms.CharField(required=False, label=_('Image URL'),
+ widget=forms.TextInput(
+ attrs={ATTRIBUTE_PLACEHOLDER:
+ 'http://example.com/image.png'}))
# This field is for spam prevention only
email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
@@ -137,18 +165,22 @@ class PostForm(NeboardForm):
def clean_image(self):
image = self.cleaned_data['image']
- if image:
- if image.size > board_settings.MAX_IMAGE_SIZE:
- raise forms.ValidationError(
- _('Image must be less than %s bytes')
- % str(board_settings.MAX_IMAGE_SIZE))
+
+ self._validate_image(image)
+
+ return image
+
+ def clean_image_url(self):
+ url = self.cleaned_data['image_url']
- md5 = hashlib.md5()
- for chunk in image.chunks():
- md5.update(chunk)
- image_hash = md5.hexdigest()
- if PostImage.objects.filter(hash=image_hash).exists():
- raise forms.ValidationError(ERROR_IMAGE_DUPLICATE)
+ image = None
+ if url:
+ image = self._get_image_from_url(url)
+
+ if not image:
+ raise forms.ValidationError(_('Invalid URL'))
+
+ self._validate_image(image)
return image
@@ -170,14 +202,29 @@ class PostForm(NeboardForm):
return cleaned_data
+ def get_image(self):
+ """
+ Gets image from file or URL.
+ """
+
+ image = self.cleaned_data['image']
+ return image if image else self.cleaned_data['image_url']
+
def _clean_text_image(self):
text = self.cleaned_data.get('text')
- image = self.cleaned_data.get('image')
+ image = self.get_image()
if (not text) and (not image):
error_message = _('Either text or image must be entered.')
self._errors['text'] = self.error_class([error_message])
+ def _validate_image(self, image):
+ if image:
+ if image.size > board_settings.MAX_IMAGE_SIZE:
+ raise forms.ValidationError(
+ _('Image must be less than %s bytes')
+ % str(board_settings.MAX_IMAGE_SIZE))
+
def _validate_posting_speed(self):
can_post = True
@@ -200,11 +247,56 @@ class PostForm(NeboardForm):
if can_post:
self.session[LAST_POST_TIME] = time.time()
+ def _get_image_from_url(self, url: str) -> SimpleUploadedFile:
+ """
+ Gets an image file from URL.
+ """
+
+ img_temp = None
+
+ try:
+ # Verify content headers
+ response_head = requests.head(url, verify=False)
+ content_type = response_head.headers['content-type'].split(';')[0]
+ if content_type in CONTENT_TYPE_IMAGE:
+ length_header = response_head.headers.get('content-length')
+ if length_header:
+ length = int(length_header)
+ if length > board_settings.MAX_IMAGE_SIZE:
+ raise forms.ValidationError(
+ _('Image must be less than %s bytes')
+ % str(board_settings.MAX_IMAGE_SIZE))
+
+ # Get the actual content into memory
+ response = requests.get(url, verify=False, stream=True)
+
+ # Download image, stop if the size exceeds limit
+ size = 0
+ content = b''
+ for chunk in response.iter_content(IMAGE_DOWNLOAD_CHUNK_BYTES):
+ size += len(chunk)
+ if size > board_settings.MAX_IMAGE_SIZE:
+ # TODO Dedup this code into a method
+ raise forms.ValidationError(
+ _('Image must be less than %s bytes')
+ % str(board_settings.MAX_IMAGE_SIZE))
+ content += chunk
+
+ if response.status_code == HTTP_RESULT_OK and content:
+ # Set a dummy file name that will be replaced
+ # anyway, just keep the valid extension
+ filename = 'image.' + content_type.split('/')[1]
+ img_temp = SimpleUploadedFile(filename, content,
+ content_type)
+ except Exception:
+ # Just return no image
+ pass
+
+ return img_temp
+
class ThreadForm(PostForm):
- regex_tags = re.compile(r'^[\w\s\d]+$', re.UNICODE)
-
tags = forms.CharField(
widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
max_length=100, label=_('Tags'), required=True)
@@ -212,17 +304,17 @@ class ThreadForm(PostForm):
def clean_tags(self):
tags = self.cleaned_data['tags'].strip()
- if not tags or not self.regex_tags.match(tags):
+ if not tags or not REGEX_TAGS.match(tags):
raise forms.ValidationError(
_('Inappropriate characters in tags.'))
- tag_models = []
required_tag_exists = False
for tag in tags.split():
tag_model = Tag.objects.filter(name=tag.strip().lower(),
- required=True)
+ required=True)
if tag_model.exists():
required_tag_exists = True
+ break
if not required_tag_exists:
raise forms.ValidationError(_('Need at least 1 required tag.'))
@@ -239,67 +331,16 @@ class SettingsForm(NeboardForm):
theme = forms.ChoiceField(choices=settings.THEMES,
label=_('Theme'))
-
-
-class AddTagForm(NeboardForm):
+ username = forms.CharField(label=_('User name'), required=False)
- tag = forms.CharField(max_length=TAG_MAX_LENGTH, label=LABEL_TAG)
- method = forms.CharField(widget=forms.HiddenInput(), initial='add_tag')
-
- def clean_tag(self):
- tag = self.cleaned_data['tag']
+ def clean_username(self):
+ username = self.cleaned_data['username']
- regex_tag = re.compile(REGEX_TAG, re.UNICODE)
- if not regex_tag.match(tag):
- raise forms.ValidationError(_('Inappropriate characters in tags.'))
+ if username and not REGEX_TAGS.match(username):
+ raise forms.ValidationError(_('Inappropriate characters.'))
- return tag
-
- def clean(self):
- cleaned_data = super(AddTagForm, self).clean()
-
- return cleaned_data
+ return username
class SearchForm(NeboardForm):
query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
-
-
-class LoginForm(NeboardForm):
-
- password = forms.CharField()
-
- session = None
-
- def clean_password(self):
- password = self.cleaned_data['password']
- if board_settings.MASTER_PASSWORD != password:
- raise forms.ValidationError(_('Invalid master password'))
-
- return password
-
- def _validate_login_speed(self):
- can_post = True
-
- if LAST_LOGIN_TIME in self.session:
- now = time.time()
- last_login_time = self.session[LAST_LOGIN_TIME]
-
- current_delay = int(now - last_login_time)
-
- if current_delay < board_settings.LOGIN_TIMEOUT:
- error_message = _('Wait %s minutes after last login') % str(
- (board_settings.LOGIN_TIMEOUT - current_delay) / 60)
- self._errors['password'] = self.error_class([error_message])
-
- can_post = False
-
- if can_post:
- self.session[LAST_LOGIN_TIME] = time.time()
-
- def clean(self):
- self._validate_login_speed()
-
- cleaned_data = super(LoginForm, self).clean()
-
- return cleaned_data
diff --git a/boards/locale/ru/LC_MESSAGES/django.mo b/boards/locale/ru/LC_MESSAGES/django.mo
index 036d4fd1b32decb9e0a867271ff8dc58f282948e..2dc8581836ef4c9805664be5d306a9304d019c94
GIT binary patch
literal 6909
zc$|$_Yit}>6~3iu8?W6uiJde_AKU;54ef62q|Z&9#;Kje)UW!{sz_jscPI9w>zQR{
z)```iCNUu?HgQ`-sairvc?m($W^2cG>^MRd5+EKk2niveNQm
9rrIkBE+-6V>bwO9lc2idL02)0-puqC&uu&
z5%^VL1@IN!{~bKm0)GTt4_pAQ0{#xT8u$k-=dZx6z<2d{Ee5NAn>21)&GUDz=KXiC
z=6&`7C9oB^1vmuU1f0Wc@F?T
z32d%lIYSjZZ={0djaIPUFH|rOFX{F9F4w#Mcx>4Zuz%R$xp0hEnA83Vg7d*F9Oy^X+Q3>jmH%;4JVq;4i8L_U
ztA=@7Rjc`{WxMaJ<+$5k%X0VEvYgNA@vLq?Q_Fgs1rC#cYuWy->sgP_t=IXxp65@j
z*Lky^?R#CfL*NR+iJZ
zk^OvlBg=UP*iAfaWFFqbV+6PwPCiWQH}Sf6Hu1XmH?jQ{n;GS1w&%gkyzf&QGkW}4
z;61>XfDZ%T1YRfj+8|07oTBw)6Y)))kUwL3-t`(O=5EyQcWP|WcsK6*^gG4k
zgMw=^T~cs7ZPo1?%5~}Ski7>(%vPwR?^iaxGvE}b)Z*p{6rV!_`$OFrv%3t)tbkZ@br*n
zi^fbw`h%`%q&?A?^9LQ*6Z;I?Hq&CClSxb8JnD-_ouMJq24bFXO4~f5*N7&|^IfYy
z=bLGnv(u)F0aG-mEnhvPZw%-cp5JU?+*MMg<4SAD7%=5f&hurzDY1p=n(2htZ({oc
zMkZsrBXY<|o8q9APMbD~^ODpsmE;y$&QICf)vDhjaT(L|u&-gut)A>3@l8*(*hV(%
zI$75uJ5qy&YoxHJCoPtpxaJHR@l4m3BTmkhIlM|MEscI>7z)_KMg}dVf{)h})7EN0
zkz6(nNl7VME&FNdJJOI@$HSmA0Q%cusWg1(N^|R_YkodwfdkSefl<(uB^%ovIas0~
zL2Y`Gha9lZO?IL*kHRGhsa*xOJ^>A{{7ovt}-nMW9%oX5&No^yOtbeY+V1?v_(|q!$D{3dmhZSDqT2vx
z-KOED1_8cr*#n;F9(0Zs=`*=WqAGi4vTUq?wjSl0DOuh>_yB=+1)8
zBvxj#X{;lf@r0++26oLl7UD^CBVM02A;rlJ46>iPM{M6XDtfRxZaHMwuu26ynx1+T
zgDP!itRaiSy?;b{StCV?^(+c!%PYDuA!tZFSi&G|UmTdF5aVf{R%Q?y;At_(GVPe2
zwBa)4UmgHm5a_{RCZ#W8EQ$rgCLW20;*cT*YFU298WkM`g$wYMKgiRZ+;^EQe^+@0wmN<13|;wAhs`olr0B
zlBq;qzlosgR}~LguT~mVAE~_2#==mwQKU`J8n8`z88$Nxf=ei;d*ZM$YucU!`@8a5ywq?eUufQu5;K-`SF%?
z{0VpjtCO;`@zKW`4>Zdsn!CDNI@)8DVy+)=^9HPRd|z(Bi}yH58SCumiK}XWMe!z8
zi+0r2-yg5riJ#oDGkM>`ch~KxtHZ>27r66E^PXyM?25N_#G4xB!H#5G%iW*pXm5^D
zS&R3$hV5mDnxx#{+uADI8{3-Y?&h|}me#$EO-)_R-Q9aI)gH*f@OY1D3?*f2WBY+#
zQh9f4>)wo&aNL2?oTPMfv9^}B=8}c=iMklM1gRLO7)&ZR-Nm
z50&PTVuwsO-fX9wG{PV$AL_UKSZ5{&O5^(xvtCl#S=IC!cJ7k=+HjY>wZ5*QpmCq-yVJ*bxowZn%$L)rz{^^>y;tFb=$F~9&2`N-xU+V^TA{=
zAIt<-f|>9{crrW=JRU3rgFozYF
z%U6ovJYMI*(cqHmz7mcFvn=B>?IVI$Fg}mJDU6*A^0aX=8ww|`v7*eBV4=(u
zW@ZW#P!Qqod@xHsJg4kifG=9##fpPK(
zO`yS9+0w**o(sp7A&L}8oudFKK<_gYy0lM0k;?%{UqHbt*z~yCsjxy^o~NiHk3c~-
znP}obRNaawd@ltTuwV>bDg>_JfprUt)8~pFfB~mC8qcX*P}9%@S|=30NJX_|0#-y8
zjMFq4FOrrZ?<<`s7a*U8OTiD1eu={B9L*;+iZK+PiliVxA>|uUJdwszO6_2_EJn2i
z75oWV%0}`x&A+P1R2jL2W}^x~ex8+PE{{jCt)wm5B3KZ?w{=!f_Rp2V@tZ1=S?h7)
zw#>9}jOeBrS5;@BJfWAz@KQIB`14Aw$*5{zZ*sx$;s8`9YOh7?_h?7upFAs4P}OFv
ztWowFm6yVzqBW)Bby`VUC@Xj2sp4MBC@P#YWfWKB@zO``
zvYePz<$xoO9IaTHqL&qkRgEcj
z{qnDUP6AM}^jm*dYr}tMCVGGK6{PXvh-h=pgMumg%(Ux_KL7v#
diff --git a/boards/locale/ru/LC_MESSAGES/django.po b/boards/locale/ru/LC_MESSAGES/django.po
--- a/boards/locale/ru/LC_MESSAGES/django.po
+++ b/boards/locale/ru/LC_MESSAGES/django.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2015-01-08 16:36+0200\n"
+"POT-Creation-Date: 2015-03-03 23:49+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -18,6 +18,10 @@ msgstr ""
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+#: admin.py:22
+msgid "{} posters were banned"
+msgstr ""
+
#: authors.py:9
msgid "author"
msgstr "автор"
@@ -34,92 +38,95 @@ msgstr "разработчик javascript"
msgid "designer"
msgstr "дизайнер"
-#: forms.py:22
+#: forms.py:33
msgid "Type message here. Use formatting panel for more advanced usage."
msgstr ""
"Вводите сообщение сюда. Используйте панель для более сложного форматирования."
-#: forms.py:23
+#: forms.py:34
msgid "tag1 several_words_tag"
msgstr "метка1 метка_из_нескольких_слов"
-#: forms.py:25
-msgid "Such image was already posted"
-msgstr "Такое изображение уже было загружено"
-
-#: forms.py:27
+#: forms.py:36
msgid "Title"
msgstr "Заголовок"
-#: forms.py:28
+#: forms.py:37
msgid "Text"
msgstr "Текст"
-#: forms.py:29
+#: forms.py:38
msgid "Tag"
msgstr "Метка"
-#: forms.py:30 templates/boards/base.html:38 templates/search/search.html:9
+#: forms.py:39 templates/boards/base.html:38 templates/search/search.html:9
#: templates/search/search.html.py:13
msgid "Search"
msgstr "Поиск"
-#: forms.py:107
+#: forms.py:131
msgid "Image"
msgstr "Изображение"
-#: forms.py:112
+#: forms.py:134
+msgid "Image URL"
+msgstr "URL изображения"
+
+#: forms.py:140
msgid "e-mail"
msgstr ""
-#: forms.py:123
+#: forms.py:151
#, python-format
msgid "Title must have less than %s characters"
msgstr "Заголовок должен иметь меньше %s символов"
-#: forms.py:132
+#: forms.py:160
#, python-format
msgid "Text must have less than %s characters"
msgstr "Текст должен быть короче %s символов"
-#: forms.py:143
+#: forms.py:181
+msgid "Invalid URL"
+msgstr "Неверный URL"
+
+#: forms.py:218
+msgid "Either text or image must be entered."
+msgstr "Текст или картинка должны быть введены."
+
+#: forms.py:225 forms.py:267 forms.py:281
#, python-format
msgid "Image must be less than %s bytes"
msgstr "Изображение должно быть менее %s байт"
-#: forms.py:178
-msgid "Either text or image must be entered."
-msgstr "Текст или картинка должны быть введены."
-
-#: forms.py:194
+#: forms.py:241
#, python-format
msgid "Wait %s seconds after last posting"
msgstr "Подождите %s секунд после последнего постинга"
-#: forms.py:210 templates/boards/rss/post.html:10 templates/boards/tags.html:7
+#: forms.py:302 templates/boards/rss/post.html:10 templates/boards/tags.html:7
msgid "Tags"
msgstr "Метки"
-#: forms.py:217 forms.py:254
+#: forms.py:309
msgid "Inappropriate characters in tags."
msgstr "Недопустимые символы в метках."
-#: forms.py:228
+#: forms.py:320
msgid "Need at least 1 required tag."
msgstr "Нужна хотя бы 1 обязательная метка."
-#: forms.py:241
+#: forms.py:333
msgid "Theme"
msgstr "Тема"
-#: forms.py:277
-msgid "Invalid master password"
-msgstr "Неверный мастер-пароль"
+#: forms.py:334
+msgid "User name"
+msgstr "Имя пользователя"
-#: forms.py:291
-#, python-format
-msgid "Wait %s minutes after last login"
-msgstr "Подождите %s минут после последнего входа"
+#: forms.py:340
+msgid "Inappropriate characters."
+msgstr "Недопустимые символы."
#: templates/boards/404.html:6
msgid "Not found"
@@ -157,20 +164,28 @@ msgstr "Все темы"
msgid "Tag management"
msgstr "Управление метками"
-#: templates/boards/base.html:39 templates/boards/settings.html:7
+#: templates/boards/base.html:41 templates/boards/notifications.html:7
+msgid "Notifications"
+msgstr "Уведомления"
+
+#: templates/boards/base.html:41
+msgid "notifications"
+msgstr "уведомлений"
+
+#: templates/boards/base.html:44 templates/boards/settings.html:7
msgid "Settings"
msgstr "Настройки"
-#: templates/boards/base.html:52
+#: templates/boards/base.html:57
msgid "Admin"
-msgstr ""
+msgstr "Администрирование"
-#: templates/boards/base.html:54
+#: templates/boards/base.html:59
#, python-format
msgid "Speed: %(ppd)s posts per day"
msgstr "Скорость: %(ppd)s сообщений в день"
-#: templates/boards/base.html:56
+#: templates/boards/base.html:61
msgid "Up"
msgstr "Вверх"
@@ -182,96 +197,95 @@ msgstr "Вход"
msgid "Insert your user id above"
msgstr "Вставьте свой ID пользователя выше"
+#: templates/boards/notifications.html:16
+#: templates/boards/posting_general.html:79 templates/search/search.html:22
+msgid "Previous page"
+msgstr "Предыдущая страница"
+
+#: templates/boards/notifications.html:26
+#: templates/boards/posting_general.html:117 templates/search/search.html:33
+msgid "Next page"
+msgstr "Следующая страница"
+
#: templates/boards/post.html:19 templates/boards/staticpages/help.html:17
msgid "Quote"
msgstr "Цитата"
-#: templates/boards/post.html:27
+#: templates/boards/post.html:35
msgid "Open"
msgstr "Открыть"
-#: templates/boards/post.html:29
+#: templates/boards/post.html:37
msgid "Reply"
msgstr "Ответ"
-#: templates/boards/post.html:36
+#: templates/boards/post.html:43
msgid "Edit"
msgstr "Изменить"
-#: templates/boards/post.html:39
+#: templates/boards/post.html:45
msgid "Edit thread"
msgstr "Изменить тему"
-#: templates/boards/post.html:71
+#: templates/boards/post.html:75
msgid "Replies"
msgstr "Ответы"
-#: templates/boards/post.html:79 templates/boards/thread.html:89
+#: templates/boards/post.html:86 templates/boards/thread.html:86
#: templates/boards/thread_gallery.html:59
msgid "messages"
msgstr "сообщений"
-#: templates/boards/post.html:80 templates/boards/thread.html:90
+#: templates/boards/post.html:87 templates/boards/thread.html:87
#: templates/boards/thread_gallery.html:60
msgid "images"
msgstr "изображений"
-#: templates/boards/post_admin.html:19
-msgid "Tags:"
-msgstr "Метки:"
-
-#: templates/boards/post_admin.html:30
-msgid "Add tag"
-msgstr "Добавить метку"
-
-#: templates/boards/posting_general.html:56
+#: templates/boards/posting_general.html:52
msgid "Show tag"
msgstr "Показывать метку"
-#: templates/boards/posting_general.html:60
+#: templates/boards/posting_general.html:56
msgid "Hide tag"
msgstr "Скрывать метку"
-#: templates/boards/posting_general.html:66
+#: templates/boards/posting_general.html:63
msgid "Edit tag"
msgstr "Изменить метку"
-#: templates/boards/posting_general.html:82 templates/search/search.html:22
-msgid "Previous page"
-msgstr "Предыдущая страница"
+#: templates/boards/posting_general.html:66
+#, python-format
+msgid "This tag has %(thread_count)s threads and %(post_count)s posts."
+msgstr "С этой меткой есть %(thread_count)s тем и %(post_count)s сообщений."
-#: templates/boards/posting_general.html:97
+#: templates/boards/posting_general.html:93
#, python-format
msgid "Skipped %(count)s replies. Open thread to see all replies."
msgstr "Пропущено %(count)s ответов. Откройте тред, чтобы увидеть все ответы."
-#: templates/boards/posting_general.html:124 templates/search/search.html:33
-msgid "Next page"
-msgstr "Следующая страница"
-
-#: templates/boards/posting_general.html:129
+#: templates/boards/posting_general.html:122
msgid "No threads exist. Create the first one!"
msgstr "Нет тем. Создайте первую!"
-#: templates/boards/posting_general.html:135
+#: templates/boards/posting_general.html:128
msgid "Create new thread"
msgstr "Создать новую тему"
-#: templates/boards/posting_general.html:140 templates/boards/preview.html:16
-#: templates/boards/thread.html:54
+#: templates/boards/posting_general.html:133 templates/boards/preview.html:16
+#: templates/boards/thread.html:53
msgid "Post"
msgstr "Отправить"
-#: templates/boards/posting_general.html:145
+#: templates/boards/posting_general.html:139
msgid "Tags must be delimited by spaces. Text or image is required."
msgstr ""
"Метки должны быть разделены пробелами. Текст или изображение обязательны."
-#: templates/boards/posting_general.html:148 templates/boards/thread.html:62
+#: templates/boards/posting_general.html:142 templates/boards/thread.html:59
msgid "Text syntax"
msgstr "Синтаксис текста"
-#: templates/boards/posting_general.html:160
+#: templates/boards/posting_general.html:154
msgid "Pages:"
msgstr "Страницы: "
@@ -291,11 +305,11 @@ msgstr "Вы модератор."
msgid "Hidden tags:"
msgstr "Скрытые метки:"
-#: templates/boards/settings.html:26
+#: templates/boards/settings.html:27
msgid "No hidden tags."
msgstr "Нет скрытых меток."
-#: templates/boards/settings.html:35
+#: templates/boards/settings.html:36
msgid "Save"
msgstr "Сохранить"
@@ -360,11 +374,10 @@ msgstr "сообщений до бамплимита"
msgid "Reply to thread"
msgstr "Ответить в тему"
-#: templates/boards/thread.html:59
-msgid "Switch mode"
-msgstr "Переключить режим"
+#: templates/boards/thread.html:85
+msgid "Update"
+msgstr "Обновить"
-#: templates/boards/thread.html:91 templates/boards/thread_gallery.html:61
+#: templates/boards/thread.html:88 templates/boards/thread_gallery.html:61
msgid "Last update: "
msgstr "Последнее обновление: "
-
diff --git a/boards/management/commands/generate_keypair.py b/boards/management/commands/generate_keypair.py
--- a/boards/management/commands/generate_keypair.py
+++ b/boards/management/commands/generate_keypair.py
@@ -4,7 +4,7 @@
from django.core.management import BaseCommand
from django.db import transaction
-from boards.models import KeyPair
+from boards.models import KeyPair, Post
class Command(BaseCommand):
@@ -12,6 +12,11 @@ class Command(BaseCommand):
@transaction.atomic
def handle(self, *args, **options):
+ first_key = not KeyPair.objects.has_primary()
key = KeyPair.objects.generate_key(
- primary=not KeyPair.objects.has_primary())
- print(key)
\ No newline at end of file
+ primary=first_key)
+ print(key)
+
+ if first_key:
+ for post in Post.objects.filter(global_id=None):
+ post.set_global_id()
\ No newline at end of file
diff --git a/boards/mdx_neboard.py b/boards/mdx_neboard.py
--- a/boards/mdx_neboard.py
+++ b/boards/mdx_neboard.py
@@ -2,6 +2,7 @@
import re
import bbcode
+from django.core.urlresolvers import reverse
import boards
@@ -153,7 +154,6 @@ def render_quote(tag_name, value, option
if 'source' in options:
source = options['source']
- result = ''
if source:
result = '' % (source, value)
else:
@@ -162,6 +162,13 @@ def render_quote(tag_name, value, option
return result
+def render_notification(tag_name, value, options, parent, content):
+ username = value.lower()
+
+ return '@{}'.format(
+ reverse('notifications', kwargs={'username': username}), username)
+
+
def preparse_text(text):
"""
Performs manual parsing before the bbcode parser is used.
@@ -176,11 +183,11 @@ def bbcode_extended(markup):
parser = bbcode.Parser(newline='')
parser.add_formatter('post', render_reflink, strip=True)
parser.add_formatter('quote', render_quote, strip=True)
+ parser.add_formatter('user', render_notification, strip=True)
parser.add_simple_formatter('comment',
'')
parser.add_simple_formatter('spoiler',
'%(value)s')
- # TODO Use here
parser.add_simple_formatter('s',
'%(value)s')
# TODO Why not use built-in tag?
diff --git a/boards/migrations/0005_auto_20150113_1128.py b/boards/migrations/0005_auto_20150113_1128.py
deleted file mode 100644
--- a/boards/migrations/0005_auto_20150113_1128.py
+++ /dev/null
@@ -1,63 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals
-
-from django.db import models, migrations
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('boards', '0004_tag_required'),
- ]
-
- operations = [
- migrations.CreateModel(
- name='GlobalId',
- fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, verbose_name='ID', serialize=False)),
- ('key', models.TextField()),
- ('key_type', models.TextField()),
- ('local_id', models.IntegerField()),
- ],
- options={
- },
- bases=(models.Model,),
- ),
- migrations.CreateModel(
- name='KeyPair',
- fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, verbose_name='ID', serialize=False)),
- ('public_key', models.TextField()),
- ('private_key', models.TextField()),
- ('key_type', models.TextField()),
- ('primary', models.BooleanField(default=False)),
- ],
- options={
- },
- bases=(models.Model,),
- ),
- migrations.CreateModel(
- name='Signature',
- fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, verbose_name='ID', serialize=False)),
- ('key_type', models.TextField()),
- ('key', models.TextField()),
- ('signature', models.TextField()),
- ],
- options={
- },
- bases=(models.Model,),
- ),
- migrations.AddField(
- model_name='post',
- name='global_id',
- field=models.OneToOneField(to='boards.GlobalId', null=True, blank=True),
- preserve_default=True,
- ),
- migrations.AddField(
- model_name='post',
- name='signature',
- field=models.ManyToManyField(to='boards.Signature', null=True, blank=True),
- preserve_default=True,
- ),
- ]
diff --git a/boards/migrations/0005_remove_thread_replies.py b/boards/migrations/0005_remove_thread_replies.py
new file mode 100644
--- /dev/null
+++ b/boards/migrations/0005_remove_thread_replies.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('boards', '0004_tag_required'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='thread',
+ name='replies',
+ ),
+ ]
diff --git a/boards/migrations/0006_auto_20150201_2130.py b/boards/migrations/0006_auto_20150201_2130.py
new file mode 100644
--- /dev/null
+++ b/boards/migrations/0006_auto_20150201_2130.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('boards', '0005_remove_thread_replies'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='thread',
+ name='bump_time',
+ field=models.DateTimeField(db_index=True),
+ ),
+ ]
diff --git a/boards/migrations/0007_auto_20150205_1247.py b/boards/migrations/0007_auto_20150205_1247.py
new file mode 100644
--- /dev/null
+++ b/boards/migrations/0007_auto_20150205_1247.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ def thread_to_threads(apps, schema_editor):
+ Post = apps.get_model('boards', 'Post')
+ for post in Post.objects.all():
+ post.threads.add(post.thread_new)
+
+ dependencies = [
+ ('boards', '0006_auto_20150201_2130'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='post',
+ name='threads',
+ field=models.ManyToManyField(to='boards.Thread'),
+ preserve_default=True,
+ ),
+ migrations.RunPython(thread_to_threads),
+ migrations.RemoveField(
+ model_name='post',
+ name='thread_new',
+ ),
+ ]
diff --git a/boards/migrations/0008_auto_20150205_1304.py b/boards/migrations/0008_auto_20150205_1304.py
new file mode 100644
--- /dev/null
+++ b/boards/migrations/0008_auto_20150205_1304.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('boards', '0007_auto_20150205_1247'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='post',
+ name='threads',
+ field=models.ManyToManyField(to='boards.Thread', db_index=True),
+ ),
+ ]
diff --git a/boards/migrations/0009_post_thread.py b/boards/migrations/0009_post_thread.py
new file mode 100644
--- /dev/null
+++ b/boards/migrations/0009_post_thread.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ def first_thread_to_thread(apps, schema_editor):
+ Post = apps.get_model('boards', 'Post')
+ for post in Post.objects.all():
+ post.thread = post.threads.first()
+ post.save(update_fields=['thread'])
+
+
+ dependencies = [
+ ('boards', '0008_auto_20150205_1304'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='post',
+ name='thread',
+ field=models.ForeignKey(related_name='pt+', to='boards.Thread', default=None, null=True),
+ preserve_default=False,
+ ),
+ migrations.RunPython(first_thread_to_thread),
+ ]
diff --git a/boards/migrations/0010_auto_20150208_1451.py b/boards/migrations/0010_auto_20150208_1451.py
new file mode 100644
--- /dev/null
+++ b/boards/migrations/0010_auto_20150208_1451.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ def clean_duplicate_tags(apps, schema_editor):
+ Tag = apps.get_model('boards', 'Tag')
+ for tag in Tag.objects.all():
+ tags_with_same_name = Tag.objects.filter(name=tag.name).all()
+ if len(tags_with_same_name) > 1:
+ for tag_duplicate in tags_with_same_name[1:]:
+ threads = tag_duplicate.thread_set.all()
+ for thread in threads:
+ thread.tags.add(tag)
+ tag_duplicate.delete()
+
+ dependencies = [
+ ('boards', '0009_post_thread'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='post',
+ name='thread',
+ field=models.ForeignKey(to='boards.Thread', related_name='pt+'),
+ preserve_default=True,
+ ),
+ migrations.RunPython(clean_duplicate_tags),
+ migrations.AlterField(
+ model_name='tag',
+ name='name',
+ field=models.CharField(db_index=True, unique=True, max_length=100),
+ preserve_default=True,
+ ),
+ migrations.AlterField(
+ model_name='tag',
+ name='required',
+ field=models.BooleanField(db_index=True, default=False),
+ preserve_default=True,
+ ),
+ ]
diff --git a/boards/migrations/0011_notification.py b/boards/migrations/0011_notification.py
new file mode 100644
--- /dev/null
+++ b/boards/migrations/0011_notification.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('boards', '0010_auto_20150208_1451'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Notification',
+ fields=[
+ ('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)),
+ ('name', models.TextField()),
+ ('post', models.ForeignKey(to='boards.Post')),
+ ],
+ options={
+ },
+ bases=(models.Model,),
+ ),
+ ]
diff --git a/boards/migrations/0012_auto_20150307_1323.py b/boards/migrations/0012_auto_20150307_1323.py
new file mode 100644
--- /dev/null
+++ b/boards/migrations/0012_auto_20150307_1323.py
@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('boards', '0011_notification'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='GlobalId',
+ fields=[
+ ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
+ ('key', models.TextField()),
+ ('key_type', models.TextField()),
+ ('local_id', models.IntegerField()),
+ ],
+ options={
+ },
+ bases=(models.Model,),
+ ),
+ migrations.CreateModel(
+ name='KeyPair',
+ fields=[
+ ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
+ ('public_key', models.TextField()),
+ ('private_key', models.TextField()),
+ ('key_type', models.TextField()),
+ ('primary', models.BooleanField(default=False)),
+ ],
+ options={
+ },
+ bases=(models.Model,),
+ ),
+ migrations.CreateModel(
+ name='Signature',
+ fields=[
+ ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
+ ('key_type', models.TextField()),
+ ('key', models.TextField()),
+ ('signature', models.TextField()),
+ ],
+ options={
+ },
+ bases=(models.Model,),
+ ),
+ migrations.AddField(
+ model_name='post',
+ name='global_id',
+ field=models.OneToOneField(to='boards.GlobalId', null=True, blank=True),
+ preserve_default=True,
+ ),
+ migrations.AddField(
+ model_name='post',
+ name='signature',
+ field=models.ManyToManyField(null=True, blank=True, to='boards.Signature'),
+ preserve_default=True,
+ ),
+ ]
diff --git a/boards/models/image.py b/boards/models/image.py
--- a/boards/models/image.py
+++ b/boards/models/image.py
@@ -56,10 +56,7 @@ class PostImage(models.Model, Viewable):
"""
if not self.pk and self.image:
- md5 = hashlib.md5()
- for chunk in self.image.chunks():
- md5.update(chunk)
- self.hash = md5.hexdigest()
+ self.hash = PostImage.get_hash(self.image)
super(PostImage, self).save(*args, **kwargs)
def __str__(self):
@@ -81,3 +78,13 @@ class PostImage(models.Model, Viewable):
self.image.url_200x150,
str(self.hash), str(self.pre_width),
str(self.pre_height), str(self.width), str(self.height))
+
+ @staticmethod
+ def get_hash(image):
+ """
+ Gets hash of an image.
+ """
+ md5 = hashlib.md5()
+ for chunk in image.chunks():
+ md5.update(chunk)
+ return md5.hexdigest()
diff --git a/boards/models/post.py b/boards/models/post.py
--- a/boards/models/post.py
+++ b/boards/models/post.py
@@ -3,23 +3,24 @@ from datetime import time as dtime
import logging
import re
import xml.etree.ElementTree as et
+from urllib.parse import unquote
from adjacent import Client
-from django.core.cache import cache
from django.core.urlresolvers import reverse
from django.db import models, transaction
from django.db.models import TextField
from django.template.loader import render_to_string
from django.utils import timezone
-from boards.models import PostImage, KeyPair, GlobalId, Signature
-from boards import settings
+from boards.models import KeyPair, GlobalId, Signature
+from boards import settings, utils
from boards.mdx_neboard import bbcode_extended
from boards.models import PostImage
from boards.models.base import Viewable
-from boards.models.thread import Thread
-from boards import utils
-from boards.utils import datetime_to_epoch
+from boards.utils import datetime_to_epoch, cached_result
+from boards.models.user import Notification
+import boards.models.thread
+
ENCODING_UNICODE = 'unicode'
@@ -30,9 +31,6 @@ WS_CHANNEL_THREAD = "thread:"
APP_LABEL_BOARDS = 'boards'
-CACHE_KEY_PPD = 'ppd'
-CACHE_KEY_POST_URL = 'post_url'
-
POSTS_PER_DAY_RANGE = 7
BAN_REASON_AUTO = 'Auto'
@@ -49,6 +47,8 @@ UNKNOWN_UA = ''
REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
+REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
+REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
TAG_MODEL = 'model'
TAG_REQUEST = 'request'
@@ -111,8 +111,8 @@ class PostManager(models.Manager):
posting_time = timezone.now()
if not thread:
- thread = Thread.objects.create(bump_time=posting_time,
- last_edit_time=posting_time)
+ thread = boards.models.thread.Thread.objects.create(
+ bump_time=posting_time, last_edit_time=posting_time)
new_thread = True
else:
new_thread = False
@@ -122,11 +122,12 @@ class PostManager(models.Manager):
post = self.create(title=title,
text=pre_text,
pub_time=posting_time,
- thread_new=thread,
poster_ip=ip,
+ thread=thread,
poster_user_agent=UNKNOWN_UA, # TODO Get UA at
# last!
last_edit_time=posting_time)
+ post.threads.add(thread)
post.set_global_id()
@@ -136,22 +137,29 @@ class PostManager(models.Manager):
post, post.poster_ip))
if image:
- post_image = PostImage.objects.create(image=image)
+ # Try to find existing image. If it exists, assign it to the post
+ # instead of createing the new one
+ image_hash = PostImage.get_hash(image)
+ existing = PostImage.objects.filter(hash=image_hash)
+ if len(existing) > 0:
+ post_image = existing[0]
+ else:
+ post_image = PostImage.objects.create(image=image)
+ logger.info('Created new image #{} for post #{}'.format(
+ post_image.id, post.id))
post.images.add(post_image)
- logger.info('Created image #{} for post #{}'.format(
- post_image.id, post.id))
- thread.replies.add(post)
list(map(thread.add_tag, tags))
if new_thread:
- Thread.objects.process_oldest_threads()
+ boards.models.thread.Thread.objects.process_oldest_threads()
else:
thread.bump()
thread.last_edit_time = posting_time
thread.save()
- self.connect_replies(post)
+ post.connect_replies()
+ post.connect_notifications()
return post
@@ -164,25 +172,7 @@ class PostManager(models.Manager):
for post in posts:
post.delete()
- # TODO This can be moved into a post
- def connect_replies(self, post):
- """
- Connects replies to a post to show them as a reflink map
- """
-
- for reply_number in post.get_replied_ids():
- ref_post = self.filter(id=reply_number)
- if ref_post.count() > 0:
- referenced_post = ref_post[0]
- referenced_post.referenced_posts.add(post)
- referenced_post.last_edit_time = post.pub_time
- referenced_post.build_refmap()
- referenced_post.save(update_fields=['refmap', 'last_edit_time'])
-
- referenced_thread = referenced_post.get_thread()
- referenced_thread.last_edit_time = post.pub_time
- referenced_thread.save(update_fields=['last_edit_time'])
-
+ @cached_result
def get_posts_per_day(self):
"""
Gets average count of posts per day for the last 7 days
@@ -191,11 +181,6 @@ class PostManager(models.Manager):
day_end = date.today()
day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
- cache_key = CACHE_KEY_PPD + str(day_end)
- ppd = cache.get(cache_key)
- if ppd:
- return ppd
-
day_time_start = timezone.make_aware(datetime.combine(
day_start, dtime()), timezone.get_current_timezone())
day_time_end = timezone.make_aware(datetime.combine(
@@ -207,7 +192,6 @@ class PostManager(models.Manager):
ppd = posts_per_period / POSTS_PER_DAY_RANGE
- cache.set(cache_key, ppd)
return ppd
# TODO Make a separate sync facade?
@@ -295,7 +279,8 @@ class PostManager(models.Manager):
# TODO Throw an exception?
pass
- def _preparse_text(self, text):
+ # TODO Make a separate parser module and move preparser there
+ def _preparse_text(self, text: str) -> str:
"""
Preparses text to change patterns like '>>' to a proper bbcode
tags.
@@ -304,6 +289,9 @@ class PostManager(models.Manager):
for key, value in PREPARSE_PATTERNS.items():
text = re.sub(key, value, text, flags=re.MULTILINE)
+ for link in REGEX_URL.findall(text):
+ text = text.replace(link, unquote(link))
+
return text
@@ -327,19 +315,15 @@ class Post(models.Model, Viewable):
poster_ip = models.GenericIPAddressField()
poster_user_agent = models.TextField()
- thread_new = models.ForeignKey('Thread', null=True, default=None,
- db_index=True)
last_edit_time = models.DateTimeField()
- # Replies to the post
referenced_posts = models.ManyToManyField('Post', symmetrical=False,
null=True,
blank=True, related_name='rfp+',
db_index=True)
-
- # Replies map. This is built from the referenced posts list to speed up
- # page loading (no need to get all the referenced posts from the database).
refmap = models.TextField(null=True, blank=True)
+ threads = models.ManyToManyField('Thread', db_index=True)
+ thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
# Global ID with author key. If the message was downloaded from another
# server, this indicates the server.
@@ -367,26 +351,16 @@ class Post(models.Model, Viewable):
Builds a replies map string from replies list. This is a cache to stop
the server from recalculating the map on every post show.
"""
- map_string = ''
+ post_urls = ['>>{}'.format(
+ refpost.get_url(), refpost.id) for refpost in self.referenced_posts.all()]
- first = True
- for refpost in self.referenced_posts.all():
- if not first:
- map_string += ', '
- map_string += '>>%s' % (refpost.get_url(),
- refpost.id)
- first = False
-
- self.refmap = map_string
+ self.refmap = ', '.join(post_urls)
def get_sorted_referenced_posts(self):
return self.refmap
def is_referenced(self) -> bool:
- if not self.refmap:
- return False
- else:
- return len(self.refmap) > 0
+ return self.refmap and len(self.refmap) > 0
def is_opening(self) -> bool:
"""
@@ -407,39 +381,36 @@ class Post(models.Model, Viewable):
thread.last_edit_time = edit_time
thread.save(update_fields=['last_edit_time'])
- def get_url(self, thread=None):
+ @cached_result
+ def get_url(self):
"""
Gets full url to the post.
"""
- cache_key = CACHE_KEY_POST_URL + str(self.id)
- link = cache.get(cache_key)
+ thread = self.get_thread()
- if not link:
- if not thread:
- thread = self.get_thread()
+ opening_id = thread.get_opening_post_id()
- opening_id = thread.get_opening_post_id()
-
- if self.id != opening_id:
- link = reverse('thread', kwargs={
- 'post_id': opening_id}) + '#' + str(self.id)
- else:
- link = reverse('thread', kwargs={'post_id': self.id})
-
- cache.set(cache_key, link)
+ if self.id != opening_id:
+ link = reverse('thread', kwargs={
+ 'post_id': opening_id}) + '#' + str(self.id)
+ else:
+ link = reverse('thread', kwargs={'post_id': self.id})
return link
- def get_thread(self) -> Thread:
+ def get_thread(self):
+ return self.thread
+
+ def get_threads(self):
"""
Gets post's thread.
"""
- return self.thread_new
+ return self.threads
def get_referenced_posts(self):
- return self.referenced_posts.only('id', 'thread_new')
+ return self.referenced_posts.only('id', 'threads')
def get_view(self, moderator=False, need_open_link=False,
truncated=False, *args, **kwargs):
@@ -449,8 +420,8 @@ class Post(models.Model, Viewable):
are same for every post and don't need to be computed over and over.
"""
+ thread = self.get_thread()
is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
- thread = kwargs.get(PARAMETER_THREAD, self.get_thread())
can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump())
if is_opening:
@@ -477,23 +448,24 @@ class Post(models.Model, Viewable):
def delete(self, using=None):
"""
- Deletes all post images and the post itself. If the post is opening,
- thread with all posts is deleted.
+ Deletes all post images and the post itself.
"""
- self.images.all().delete()
+ for image in self.images.all():
+ image_refs_count = Post.objects.filter(images__in=[image]).count()
+ if image_refs_count == 1:
+ image.delete()
+
self.signature.all().delete()
if self.global_id:
self.global_id.delete()
- if self.is_opening():
- self.get_thread().delete()
- else:
- thread = self.get_thread()
- thread.last_edit_time = timezone.now()
- thread.save()
+ thread = self.get_thread()
+ thread.last_edit_time = timezone.now()
+ thread.save()
super(Post, self).delete(using)
+
logging.getLogger('boards.post.delete').info(
'Deleted post {}'.format(self))
@@ -573,7 +545,7 @@ class Post(models.Model, Viewable):
post_json['image_preview'] = post_image.image.url_200x150
if include_last_update:
post_json['bump_time'] = datetime_to_epoch(
- self.thread_new.bump_time)
+ self.get_thread().bump_time)
return post_json
def send_to_websocket(self, request, recursive=True):
@@ -606,7 +578,7 @@ class Post(models.Model, Viewable):
# If post is in this thread, its thread was already notified.
# Otherwise, notify its thread separately.
- if ref_post.thread_new_id != thread_id:
+ if ref_post.get_thread().id != thread_id:
ref_post.send_to_websocket(request, recursive=False)
def save(self, force_insert=False, force_update=False, using=None,
@@ -620,3 +592,40 @@ class Post(models.Model, Viewable):
def get_raw_text(self) -> str:
return self.text
+
+ def get_absolute_id(self) -> str:
+ """
+ If the post has many threads, shows its main thread OP id in the post
+ ID.
+ """
+
+ if self.get_threads().count() > 1:
+ return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
+ else:
+ return str(self.id)
+
+ def connect_notifications(self):
+ for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
+ user_name = reply_number.group(1).lower()
+ Notification.objects.get_or_create(name=user_name, post=self)
+
+ def connect_replies(self):
+ """
+ Connects replies to a post to show them as a reflink map
+ """
+
+ for post_id in self.get_replied_ids():
+ ref_post = Post.objects.filter(id=post_id)
+ if ref_post.count() > 0:
+ referenced_post = ref_post[0]
+ referenced_post.referenced_posts.add(self)
+ referenced_post.last_edit_time = self.pub_time
+ referenced_post.build_refmap()
+ referenced_post.save(update_fields=['refmap', 'last_edit_time'])
+
+ referenced_threads = referenced_post.get_threads().all()
+ for thread in referenced_threads:
+ thread.last_edit_time = self.pub_time
+ thread.save(update_fields=['last_edit_time'])
+
+ self.threads.add(thread)
diff --git a/boards/models/tag.py b/boards/models/tag.py
--- a/boards/models/tag.py
+++ b/boards/models/tag.py
@@ -4,6 +4,7 @@ from django.db.models import Count
from django.core.urlresolvers import reverse
from boards.models.base import Viewable
+from boards.utils import cached_result
__author__ = 'neko259'
@@ -33,8 +34,8 @@ class Tag(models.Model, Viewable):
app_label = 'boards'
ordering = ('name',)
- name = models.CharField(max_length=100, db_index=True)
- required = models.BooleanField(default=False)
+ name = models.CharField(max_length=100, db_index=True, unique=True)
+ required = models.BooleanField(default=False, db_index=True)
def __str__(self):
return self.name
@@ -69,3 +70,7 @@ class Tag(models.Model, Viewable):
return render_to_string('boards/tag.html', {
'tag': self,
})
+
+ @cached_result
+ def get_post_count(self):
+ return self.get_threads().aggregate(num_posts=Count('post'))['num_posts']
diff --git a/boards/models/thread.py b/boards/models/thread.py
--- a/boards/models/thread.py
+++ b/boards/models/thread.py
@@ -1,9 +1,13 @@
import logging
+
from django.db.models import Count, Sum
from django.utils import timezone
-from django.core.cache import cache
from django.db import models
+
from boards import settings
+from boards.utils import cached_result
+from boards.models.post import Post
+
__author__ = 'neko259'
@@ -11,9 +15,6 @@ from boards import settings
logger = logging.getLogger(__name__)
-CACHE_KEY_OPENING_POST = 'opening_post_id'
-
-
class ThreadManager(models.Manager):
def process_oldest_threads(self):
"""
@@ -50,10 +51,8 @@ class Thread(models.Model):
app_label = 'boards'
tags = models.ManyToManyField('Tag')
- bump_time = models.DateTimeField()
+ bump_time = models.DateTimeField(db_index=True)
last_edit_time = models.DateTimeField()
- replies = models.ManyToManyField('Post', symmetrical=False, null=True,
- blank=True, related_name='tre+')
archived = models.BooleanField(default=False)
bumpable = models.BooleanField(default=True)
@@ -78,10 +77,10 @@ class Thread(models.Model):
logger.info('Bumped thread %d' % self.id)
def get_reply_count(self):
- return self.replies.count()
+ return self.get_replies().count()
def get_images_count(self):
- return self.replies.annotate(images_count=Count(
+ return self.get_replies().annotate(images_count=Count(
'images')).aggregate(Sum('images_count'))['images_count__sum']
def can_bump(self):
@@ -121,12 +120,17 @@ class Thread(models.Model):
Gets sorted thread posts
"""
- query = self.replies.order_by('pub_time').prefetch_related('images')
+ query = Post.objects.filter(threads__in=[self])
+ query = query.order_by('pub_time').prefetch_related('images', 'thread', 'threads')
if view_fields_only:
query = query.defer('poster_user_agent')
return query.all()
def get_replies_with_images(self, view_fields_only=False):
+ """
+ Gets replies that have at least one image attached
+ """
+
return self.get_replies(view_fields_only).annotate(images_count=Count(
'images')).filter(images_count__gt=0)
@@ -142,25 +146,20 @@ class Thread(models.Model):
Gets the first post of the thread
"""
- query = self.replies.order_by('pub_time')
+ query = self.get_replies().order_by('pub_time')
if only_id:
query = query.only('id')
opening_post = query.first()
return opening_post
+ @cached_result
def get_opening_post_id(self):
"""
Gets ID of the first thread post.
"""
- cache_key = CACHE_KEY_OPENING_POST + str(self.id)
- opening_post_id = cache.get(cache_key)
- if not opening_post_id:
- opening_post_id = self.get_opening_post(only_id=True).id
- cache.set(cache_key, opening_post_id)
-
- return opening_post_id
+ return self.get_opening_post(only_id=True).id
def __unicode__(self):
return str(self.id)
@@ -173,10 +172,14 @@ class Thread(models.Model):
return self.get_opening_post().pub_time
def delete(self, using=None):
- if self.replies.exists():
- self.replies.all().delete()
+ """
+ Deletes thread with all replies.
+ """
+
+ for reply in self.get_replies().all():
+ reply.delete()
super(Thread, self).delete(using)
def __str__(self):
- return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
\ No newline at end of file
+ return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
diff --git a/boards/models/user.py b/boards/models/user.py
--- a/boards/models/user.py
+++ b/boards/models/user.py
@@ -1,5 +1,7 @@
from django.db import models
+import boards.models.post
+
__author__ = 'neko259'
BAN_REASON_AUTO = 'Auto'
@@ -18,3 +20,27 @@ class Ban(models.Model):
def __str__(self):
return self.ip
+
+
+class NotificationManager(models.Manager):
+ def get_notification_posts(self, username: str, last: int = None):
+ i_username = username.lower()
+
+ posts = boards.models.post.Post.objects.filter(notification__name=i_username)
+ if last is not None:
+ posts = posts.filter(id__gt=last)
+ posts = posts.order_by('-id')
+
+ return posts
+
+
+class Notification(models.Model):
+
+ class Meta:
+ app_label = 'boards'
+
+ objects = NotificationManager()
+
+ post = models.ForeignKey('Post')
+ name = models.TextField()
+
diff --git a/boards/settings.py b/boards/settings.py
--- a/boards/settings.py
+++ b/boards/settings.py
@@ -1,22 +1,3 @@
-VERSION = '2.2.3 Miyu'
-SITE_NAME = 'Neboard'
-
-CACHE_TIMEOUT = 600 # Timeout for caching, if cache is used
-LOGIN_TIMEOUT = 3600 # Timeout between login tries
-MAX_TEXT_LENGTH = 30000 # Max post length in characters
-MAX_IMAGE_SIZE = 8 * 1024 * 1024 # Max image size
+from boards.default_settings import *
-# Thread bumplimit
-MAX_POSTS_PER_THREAD = 10
-# Old posts will be archived or deleted if this value is reached
-MAX_THREAD_COUNT = 5
-THREADS_PER_PAGE = 3
-DEFAULT_THEME = 'md'
-LAST_REPLIES_COUNT = 3
-
-# Enable archiving threads instead of deletion when the thread limit is reached
-ARCHIVE_THREADS = True
-# Limit posting speed
-LIMIT_POSTING_SPEED = False
-# Thread update
-WEBSOCKETS_ENABLED = True
+# Site-specific settings go here
\ No newline at end of file
diff --git a/boards/static/css/md/base_page.css b/boards/static/css/md/base_page.css
--- a/boards/static/css/md/base_page.css
+++ b/boards/static/css/md/base_page.css
@@ -3,7 +3,7 @@
font-weight: inherit;
}
-b {
+b, strong {
font-weight: bold;
}
@@ -344,6 +344,11 @@ li {
color: #e99d41;
float: right;
font-weight: bold;
+ opacity: 0.4;
+}
+
+.moderator_info:hover {
+ opacity: 1;
}
.refmap {
@@ -444,7 +449,6 @@ pre {
.tag_item {
display: inline-block;
- border: 1px dashed #666;
margin: 0.2ex;
padding: 0.1ex;
}
@@ -495,3 +499,19 @@ ul {
.hljs, .hljs-subst, .hljs-tag .hljs-title, .lisp .hljs-title, .clojure .hljs-built_in, .nginx .hljs-title {
color: #fff;
}
+
+#up {
+ position: fixed;
+ bottom: 5px;
+ right: 5px;
+ border: 1px solid #777;
+ background: #000;
+ padding: 4px;
+}
+
+.user-cast {
+ border: solid #ffffff 1px;
+ padding: .2ex;
+ background: #152154;
+ color: #fff;
+}
\ No newline at end of file
diff --git a/boards/static/css/sw/base_page.css b/boards/static/css/sw/base_page.css
--- a/boards/static/css/sw/base_page.css
+++ b/boards/static/css/sw/base_page.css
@@ -369,3 +369,20 @@ li {
#id_q {
margin-left: 1ex;
}
+
+.br {
+ margin-top: 0.5em;
+ margin-bottom: 0.5em;
+}
+
+.message, .refmap {
+ margin-top: .5em;
+}
+
+.user-cast {
+ padding: 0.2em .5ex;
+ background: #008;
+ color: #FFF;
+ display: inline-block;
+ text-decoration: none;
+}
\ No newline at end of file
diff --git a/boards/static/js/form.js b/boards/static/js/form.js
--- a/boards/static/js/form.js
+++ b/boards/static/js/form.js
@@ -1,5 +1,3 @@
-var isCompact = false;
-
$('input[name=image]').wrap($(''));
$('body').on('change', 'input[name=image]', function(event) {
@@ -21,21 +19,9 @@ var isCompact = false;
}
});
-var fullForm = $('.swappable-form-full');
-
-function swapForm() {
- if (isCompact) {
- // TODO Use IDs (change the django form code) instead of absolute numbers
- fullForm.find('textarea').appendTo(fullForm.find('.form-row')[4].children[0]);
- fullForm.find('.file_wrap').appendTo(fullForm.find('.form-row')[7].children[0]);
- fullForm.find('.form-row').show();
-
- scrollToBottom();
- } else {
- fullForm.find('textarea').appendTo($('.compact-form-text'));
- fullForm.find('.file_wrap').insertBefore($('.compact-form-text'));
- fullForm.find('.form-row').hide();
- fullForm.find('input[type=text]').val('');
+var form = $('#form');
+$('textarea').keypress(function(event) {
+ if (event.which == 13 && event.ctrlKey) {
+ form.submit();
}
- isCompact = !isCompact;
-}
+});
\ No newline at end of file
diff --git a/boards/static/js/thread.js b/boards/static/js/thread.js
--- a/boards/static/js/thread.js
+++ b/boards/static/js/thread.js
@@ -23,6 +23,8 @@
for the JavaScript code in this page.
*/
+var $html = $("html, body");
+
function moveCaretToEnd(el) {
if (typeof el.selectionStart == "number") {
el.selectionStart = el.selectionEnd = el.value.length;
@@ -48,14 +50,9 @@ function addQuickReply(postId) {
$(textAreaId).focus();
moveCaretToEnd(textarea);
- $("html, body").animate({ scrollTop: $(textAreaId).offset().top }, "slow");
+ $html.animate({ scrollTop: $(textAreaId).offset().top }, "slow");
}
function scrollToBottom() {
- var $target = $('html,body');
- $target.animate({scrollTop: $target.height()}, "fast");
-}
-
-$(document).ready(function() {
- swapForm();
-})
+ $html.animate({scrollTop: $html.height()}, "fast");
+}
\ No newline at end of file
diff --git a/boards/static/js/thread_update.js b/boards/static/js/thread_update.js
--- a/boards/static/js/thread_update.js
+++ b/boards/static/js/thread_update.js
@@ -25,7 +25,6 @@
var wsUser = '';
-var loading = false;
var unreadPosts = 0;
var documentOriginalTitle = '';
@@ -67,7 +66,7 @@ function connectWebsocket() {
// For the case we closed the browser and missed some updates
getThreadDiff();
- $('#autoupdate').text('[+]');
+ $('#autoupdate').hide();
});
centrifuge.connect();
@@ -94,8 +93,6 @@ function getThreadDiff() {
var post = $(postText);
updatePost(post)
-
- lastPost = post;
}
var updatedPosts = data.updated;
@@ -297,8 +294,7 @@ function showAsErrors(form, text) {
form.children('.form-errors').remove();
if (text.length > 0) {
- var errorList = $('