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+EKk2niveNQm9rrIkBE+-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=JRU3rg&#6FozYF 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 = '
%s
%s
' % (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', '//%(value)s') 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 = $('
' + text - + '
'); + var errorList = $('
' + text + '
'); errorList.appendTo(form); } } @@ -331,4 +327,6 @@ function processNewPost(post) { resetForm(form); } + + $('#autoupdate').click(getThreadDiff); }); diff --git a/boards/templates/boards/base.html b/boards/templates/boards/base.html --- a/boards/templates/boards/base.html +++ b/boards/templates/boards/base.html @@ -28,14 +28,19 @@ @@ -53,7 +58,7 @@ {% with ppd=posts_per_day|floatformat:2 %} {% blocktrans %}Speed: {{ ppd }} posts per day{% endblocktrans %} {% endwith %} - {% trans 'Up' %} + {% trans 'Up' %}
diff --git a/boards/templates/search/search.html b/boards/templates/boards/notifications.html copy from boards/templates/search/search.html copy to boards/templates/boards/notifications.html --- a/boards/templates/search/search.html +++ b/boards/templates/boards/notifications.html @@ -3,35 +3,27 @@ {% load board %} {% load i18n %} +{% block head %} + {{ site_name }} - {% trans 'Notifications' %} - {{ notification_username }} +{% endblock %} + {% block content %} -
-
-

{% trans 'Search' %}

-
- {{ form.as_div }} -
- -
-
-
-
+ {% if page %} {% if page.has_previous %} {% endif %} - {% for result in page.object_list %} - {{ result.object.get_search_view }} + {% for post in page.object_list %} + {% post_view post %} {% endfor %} {% if page.has_next %} {% endif %} {% endif %} diff --git a/boards/templates/boards/post.html b/boards/templates/boards/post.html --- a/boards/templates/boards/post.html +++ b/boards/templates/boards/post.html @@ -13,12 +13,12 @@ {% endif %} {% endif %} @@ -76,8 +73,6 @@ - {% with can_bump=thread.can_bump %} - {% post_view thread.get_opening_post moderator is_opening=True thread=thread can_bump=can_bump truncated=True need_open_link=True %} + {% post_view thread.get_opening_post moderator is_opening=True thread=thread truncated=True need_open_link=True %} {% if not thread.archived %} {% with last_replies=thread.get_last_replies %} {% if last_replies %} @@ -102,13 +96,12 @@ {% endif %}
{% for post in last_replies %} - {% post_view post moderator=moderator is_opening=False thread=thread can_bump=can_bump truncated=True %} + {% post_view post is_opening=False moderator=moderator truncated=True %} {% endfor %}
{% endif %} {% endwith %} {% endif %} - {% endwith %}
{% endcache %} {% endfor %} @@ -118,8 +111,6 @@
{% trans "Create new thread" %}
-
{% csrf_token %} + {% csrf_token %} {{ form.as_div }}
+ (ctrl-enter)
@@ -163,8 +155,6 @@ {% trans 'Hidden tags:' %} {% for tag in hidden_tags %} - - #{{ tag.name }}{% if not forloop.last %},{% endif %} + {% autoescape off %} + {{ tag.get_view }} + {% endautoescape %} {% endfor %}

{% else %} diff --git a/boards/templates/boards/thread.html b/boards/templates/boards/thread.html --- a/boards/templates/boards/thread.html +++ b/boards/templates/boards/thread.html @@ -17,7 +17,7 @@ {% if bumpable %} @@ -34,30 +34,27 @@ {% with can_bump=thread.can_bump %} {% for post in thread.get_replies %} {% with is_opening=forloop.first %} - {% post_view post moderator=moderator is_opening=is_opening thread=thread bumpable=can_bump opening_post_id=opening_post.id %} + {% post_view post moderator=moderator is_opening=is_opening bumpable=can_bump opening_post_id=opening_post.id %} {% endwith %} {% endfor %} {% endwith %}
{% if not thread.archived %} -
+
{% trans "Reply to thread" %} #{{ opening_post.id }}
-
{% csrf_token %} + {% csrf_token %}
{{ form.as_div }}
+ (ctrl-enter)
- - {% trans 'Switch mode' %} -
@@ -85,10 +82,10 @@ data-ws-host="{{ ws_host }}" data-ws-port="{{ ws_port }}"> {% cache 600 thread_meta thread.last_edit_time moderator LANGUAGE_CODE %} - [-] + {{ thread.get_reply_count }}/{{ max_replies }} {% trans 'messages' %}, {{ thread.get_images_count }} {% trans 'images' %}. - {% trans 'Last update: ' %}{{ thread.last_edit_time }} + {% trans 'Last update: ' %}{{ thread.last_edit_time|date:'r' }} [RSS] {% endcache %} diff --git a/boards/templates/boards/thread_gallery.html b/boards/templates/boards/thread_gallery.html --- a/boards/templates/boards/thread_gallery.html +++ b/boards/templates/boards/thread_gallery.html @@ -17,7 +17,7 @@ {% cache 600 thread_gallery_view thread.id thread.last_edit_time LANGUAGE_CODE request.get_host %}
@@ -54,11 +54,11 @@ {% get_current_language as LANGUAGE_CODE %} - {% cache 600 thread_meta thread.last_edit_time moderator LANGUAGE_CODE %} + {% cache 600 thread_gallery_meta thread.last_edit_time moderator LANGUAGE_CODE %} {{ thread.get_reply_count }}/{{ max_replies }} {% trans 'messages' %}, {{ thread.get_images_count }} {% trans 'images' %}. - {% trans 'Last update: ' %}{{ thread.last_edit_time }} + {% trans 'Last update: ' %}{{ thread.last_edit_time|date:'r' }} [RSS] {% endcache %} diff --git a/boards/templatetags/board.py b/boards/templatetags/board.py --- a/boards/templatetags/board.py +++ b/boards/templatetags/board.py @@ -1,6 +1,12 @@ +import re from django.shortcuts import get_object_or_404 from django import template +ELLIPSIZER = '...' + +REGEX_LINES = re.compile(r'(
)', re.U | re.S) +REGEX_TAG = re.compile(r'<(/)?([^ ]+?)(?:(\s*/)| .*?)?>', re.S) + register = template.Library() @@ -25,18 +31,6 @@ def post_url(*args, **kwargs): return post.get_url() -@register.simple_tag(name='post_object_url') -def post_object_url(*args, **kwargs): - post = args[0] - - if 'thread' in kwargs: - post_thread = kwargs['thread'] - else: - post_thread = None - - return post.get_url(thread=post_thread) - - @register.simple_tag(name='image_actions') def image_actions(*args, **kwargs): image_link = args[0] @@ -65,10 +59,7 @@ def post_view(post, moderator=False, nee else: is_opening = post.is_opening() - if 'thread' in kwargs: - thread = kwargs['thread'] - else: - thread = post.get_thread() + thread = post.get_thread() if 'can_bump' in kwargs: can_bump = kwargs['can_bump'] @@ -87,3 +78,68 @@ def post_view(post, moderator=False, nee 'truncated': truncated, 'opening_post_id': opening_post_id, } + + +@register.filter(is_safe=True) +def truncate_lines(text, length): + if length <= 0: + return '' + + html4_singlets = ( + 'br', 'col', 'link', 'base', 'img', + 'param', 'area', 'hr', 'input' + ) + + # Count non-HTML chars/words and keep note of open tags + pos = 0 + end_text_pos = 0 + current_len = 0 + open_tags = [] + + while current_len <= length: + m = REGEX_LINES.search(text, pos) + if not m: + # Checked through whole string + break + pos = m.end(0) + if m.group(1): + # It's an actual non-HTML word or char + current_len += 1 + if current_len == length: + end_text_pos = m.start(0) + continue + # Check for tag + tag = REGEX_TAG.match(m.group(0)) + if not tag or current_len >= length: + # Don't worry about non tags or tags after our truncate point + continue + closing_tag, tagname, self_closing = tag.groups() + # Element names are always case-insensitive + tagname = tagname.lower() + if self_closing or tagname in html4_singlets: + pass + elif closing_tag: + # Check for match in open tags list + try: + i = open_tags.index(tagname) + except ValueError: + pass + else: + # SGML: An end tag closes, back to the matching start tag, + # all unclosed intervening start tags with omitted end tags + open_tags = open_tags[i + 1:] + else: + # Add it to the start of the open tags list + open_tags.insert(0, tagname) + + if current_len <= length: + return text + out = text[:end_text_pos] + + if not out.endswith(ELLIPSIZER): + out += ELLIPSIZER + # Close any tags still open + for tag in open_tags: + out += '' % tag + # Return string + return out diff --git a/boards/tests/test_api.py b/boards/tests/test_api.py --- a/boards/tests/test_api.py +++ b/boards/tests/test_api.py @@ -1,11 +1,11 @@ import simplejson from django.test import TestCase +from boards.views import api from boards.models import Tag, Post from boards.tests.mocks import MockRequest from boards.utils import datetime_to_epoch -from boards.views.api import api_get_threaddiff class ApiTest(TestCase): @@ -17,9 +17,8 @@ class ApiTest(TestCase): last_edit_time = datetime_to_epoch(opening_post.last_edit_time) # Check the exact timestamp post was added - empty_response = api_get_threaddiff(MockRequest(), - str(opening_post.thread_new.id), - str(last_edit_time)) + empty_response = api.api_get_threaddiff( + MockRequest(), str(opening_post.get_thread().id), str(last_edit_time)) diff = simplejson.loads(empty_response.content) self.assertEqual(0, len(diff['added']), 'There must be no added posts in the diff.') @@ -28,23 +27,43 @@ class ApiTest(TestCase): reply = Post.objects.create_post(title='', text='[post]%d[/post]\ntext' % opening_post.id, - thread=opening_post.thread_new) + thread=opening_post.get_thread()) # Check the timestamp before post was added - response = api_get_threaddiff(MockRequest(), - str(opening_post.thread_new.id), - str(last_edit_time)) + response = api.api_get_threaddiff( + MockRequest(), str(opening_post.get_thread().id), str(last_edit_time)) diff = simplejson.loads(response.content) self.assertEqual(1, len(diff['added']), 'There must be 1 added posts in the diff.') self.assertEqual(1, len(diff['updated']), 'There must be 1 updated posts in the diff.') - empty_response = api_get_threaddiff(MockRequest(), - str(opening_post.thread_new.id), + empty_response = api.api_get_threaddiff(MockRequest(), + str(opening_post.get_thread().id), str(datetime_to_epoch(reply.last_edit_time))) diff = simplejson.loads(empty_response.content) self.assertEqual(0, len(diff['added']), 'There must be no added posts in the diff.') self.assertEqual(0, len(diff['updated']), - 'There must be no updated posts in the diff.') \ No newline at end of file + 'There must be no updated posts in the diff.') + + def test_get_threads(self): + # Create 10 threads + tag = Tag.objects.create(name='test_tag') + for i in range(5): + Post.objects.create_post(title='title', text='text', tags=[tag]) + + # Get all threads + response = api.api_get_threads(MockRequest(), 5) + diff = simplejson.loads(response.content) + self.assertEqual(5, len(diff), 'Invalid thread list response.') + + # Get less threads then exist + response = api.api_get_threads(MockRequest(), 3) + diff = simplejson.loads(response.content) + self.assertEqual(3, len(diff), 'Invalid thread list response.') + + # Get more threads then exist + response = api.api_get_threads(MockRequest(), 10) + diff = simplejson.loads(response.content) + self.assertEqual(5, len(diff), 'Invalid thread list response.') diff --git a/boards/tests/test_parser.py b/boards/tests/test_parser.py --- a/boards/tests/test_parser.py +++ b/boards/tests/test_parser.py @@ -25,3 +25,10 @@ class ParserTest(TestCase): self.assertEqual('[post]12[/post]\nText', preparsed_text, 'Reflink not preparsed.') + def preparse_user(self): + raw_text = '@user\nuser@example.com\n@user\nuser @user' + preparsed_text = Post.objects._preparse_text(raw_text) + + self.assertEqual('[user]user[/user]\nuser@example.com\n[user]user[/user]\nuser [user]user[/user]', + preparsed_text, 'User link not preparsed.') + diff --git a/boards/tests/test_post.py b/boards/tests/test_post.py --- a/boards/tests/test_post.py +++ b/boards/tests/test_post.py @@ -7,7 +7,7 @@ from boards.models import Tag, Post, Thr class PostTests(TestCase): def _create_post(self): - tag = Tag.objects.create(name='test_tag') + tag, created = Tag.objects.get_or_create(name='test_tag') return Post.objects.create_post(title='title', text='text', tags=[tag]) @@ -37,7 +37,7 @@ class PostTests(TestCase): thread = opening_post.get_thread() reply = Post.objects.create_post("", "", thread=thread) - opening_post.delete() + thread.delete() self.assertFalse(Post.objects.filter(id=reply.id).exists(), 'Reply was not deleted with the thread.') @@ -76,7 +76,7 @@ class PostTests(TestCase): thread = opening_post.get_thread() - self.assertEqual(3, thread.replies.count()) + self.assertEqual(3, thread.get_replies().count()) def test_create_post_with_tag(self): """Test adding tag to post""" diff --git a/boards/urls.py b/boards/urls.py --- a/boards/urls.py +++ b/boards/urls.py @@ -6,6 +6,7 @@ from boards.views import api, tag_thread settings, all_tags from boards.views.authors import AuthorsView from boards.views.ban import BanUserView +from boards.views.notifications import NotificationView from boards.views.search import BoardSearchView from boards.views.static import StaticPageView from boards.views.preview import PostPreviewView @@ -30,10 +31,10 @@ urlpatterns = patterns('', tag_threads.TagView.as_view(), name='tag'), # /boards/thread/ - url(r'^thread/(?P\w+)/$', views.thread.ThreadView.as_view(), + url(r'^thread/(?P\w+)/$', views.thread.normal.NormalThreadView.as_view(), name='thread'), - url(r'^thread/(?P\w+)/mode/(?P\w+)/$', views.thread.ThreadView - .as_view(), name='thread_mode'), + url(r'^thread/(?P\w+)/mode/gallery/$', views.thread.gallery.GalleryThreadView.as_view(), + name='thread_gallery'), url(r'^settings/$', settings.SettingsView.as_view(), name='settings'), url(r'^tags/$', all_tags.AllTagsView.as_view(), name='tags'), @@ -66,10 +67,15 @@ urlpatterns = patterns('', name='get_thread'), url(r'^api/add_post/(?P\w+)/$', api.api_add_post, name='add_post'), + url(r'^api/notifications/(?P\w+)/$', api.api_get_notifications, + name='api_notifications'), # Search url(r'^search/$', BoardSearchView.as_view(), name='search'), + # Notifications + url(r'^notifications/(?P\w+)$', NotificationView.as_view(), name='notifications'), + # Post preview url(r'^preview/$', PostPreviewView.as_view(), name='preview'), diff --git a/boards/utils.py b/boards/utils.py --- a/boards/utils.py +++ b/boards/utils.py @@ -3,6 +3,8 @@ This module contains helper functions an """ import time import hmac +from django.core.cache import cache +from django.db.models import Model from django.utils import timezone @@ -23,6 +25,7 @@ def get_client_ip(request): return ip +# TODO The output format is not epoch because it includes microseconds def datetime_to_epoch(datetime): return int(time.mktime(timezone.localtime( datetime,timezone.get_current_timezone()).timetuple()) @@ -40,4 +43,27 @@ def get_websocket_token(user_id='', time sign.update(timestamp.encode()) token = sign.hexdigest() - return token \ No newline at end of file + return token + + +def cached_result(function): + """ + Caches method result in the Django's cache system, persisted by object name, + object name and model id if object is a Django model. + """ + def inner_func(obj, *args, **kwargs): + # TODO Include method arguments to the cache key + cache_key = obj.__class__.__name__ + '_' + function.__name__ + if isinstance(obj, Model): + cache_key += '_' + str(obj.id) + + persisted_result = cache.get(cache_key) + if persisted_result: + result = persisted_result + else: + result = function(obj, *args, **kwargs) + cache.set(cache_key, result) + + return result + + return inner_func diff --git a/boards/views/all_threads.py b/boards/views/all_threads.py --- a/boards/views/all_threads.py +++ b/boards/views/all_threads.py @@ -1,11 +1,16 @@ +from django.core.files import File +from django.core.files.temp import NamedTemporaryFile +from django.core.paginator import EmptyPage from django.db import transaction +from django.http import Http404 from django.shortcuts import render, redirect +import requests from boards import utils, settings from boards.abstracts.paginator import get_paginator from boards.abstracts.settingsmanager import get_settings_manager from boards.forms import ThreadForm, PlainErrorList -from boards.models import Post, Thread, Ban, Tag +from boards.models import Post, Thread, Ban, Tag, PostImage from boards.views.banned import BannedView from boards.views.base import BaseBoardView, CONTEXT_FORM from boards.views.posting_mixin import PostMixin @@ -32,7 +37,7 @@ class AllThreadsView(PostMixin, BaseBoar self.settings_manager = None super(AllThreadsView, self).__init__() - def get(self, request, page=DEFAULT_PAGE, form=None): + def get(self, request, page=DEFAULT_PAGE, form: ThreadForm=None): params = self.get_context_data(request=request) if not form: @@ -43,7 +48,10 @@ class AllThreadsView(PostMixin, BaseBoar settings.THREADS_PER_PAGE) paginator.current_page = int(page) - threads = paginator.page(page).object_list + try: + threads = paginator.page(page).object_list + except EmptyPage: + raise Http404() params[PARAMETER_THREADS] = threads params[CONTEXT_FORM] = form @@ -92,7 +100,7 @@ class AllThreadsView(PostMixin, BaseBoar return tags @transaction.atomic - def create_thread(self, request, form, html_response=True): + def create_thread(self, request, form: ThreadForm, html_response=True): """ Creates a new thread with an opening post. """ @@ -110,7 +118,7 @@ class AllThreadsView(PostMixin, BaseBoar title = data[FORM_TITLE] text = data[FORM_TEXT] - image = data.get(FORM_IMAGE) + image = form.get_image() text = self._remove_invalid_links(text) @@ -133,5 +141,5 @@ class AllThreadsView(PostMixin, BaseBoar Gets list of threads that will be shown on a page. """ - return Thread.objects.all().order_by('-bump_time')\ + return Thread.objects.order_by('-bump_time')\ .exclude(tags__in=self.settings_manager.get_hidden_tags()) diff --git a/boards/views/api.py b/boards/views/api.py --- a/boards/views/api.py +++ b/boards/views/api.py @@ -12,6 +12,7 @@ from boards.forms import PostForm, Plain from boards.models import Post, Thread, Tag from boards.utils import datetime_to_epoch from boards.views.thread import ThreadView +from boards.models.user import Notification __author__ = 'neko259' @@ -48,10 +49,10 @@ def api_get_threaddiff(request, thread_i 'updated': [], 'last_update': None, } - added_posts = Post.objects.filter(thread_new=thread, + added_posts = Post.objects.filter(threads__in=[thread], pub_time__gt=filter_time) \ .order_by('pub_time') - updated_posts = Post.objects.filter(thread_new=thread, + updated_posts = Post.objects.filter(threads__in=[thread], pub_time__lte=filter_time, last_edit_time__gt=filter_time) @@ -122,7 +123,6 @@ def get_post(request, post_id): return render(request, 'boards/api_post.html', context_instance=context) -# TODO Test this def api_get_threads(request, count): """ Gets the JSON thread opening posts list. @@ -152,8 +152,11 @@ def api_get_threads(request, count): opening_post = thread.get_opening_post() # TODO Add tags, replies and images count - opening_posts.append(get_post_data(opening_post.id, - include_last_update=True)) + post_data = get_post_data(opening_post.id, include_last_update=True) + post_data['bumpable'] = thread.can_bump() + post_data['archived'] = thread.archived + + opening_posts.append(post_data) return HttpResponse(content=json.dumps(opening_posts)) @@ -199,6 +202,20 @@ def api_get_thread_posts(request, openin return HttpResponse(content=json.dumps(json_data)) +def api_get_notifications(request, username): + last_notification_id_str = request.GET.get('last', None) + last_id = int(last_notification_id_str) if last_notification_id_str is not None else None + + posts = Notification.objects.get_notification_posts(username=username, + last=last_id) + + json_post_list = [] + for post in posts: + json_post_list.append(get_post_data(post.id)) + return HttpResponse(content=json.dumps(json_post_list)) + + + def api_get_post(request, post_id): """ Gets the JSON of a post. This can be diff --git a/boards/views/base.py b/boards/views/base.py --- a/boards/views/base.py +++ b/boards/views/base.py @@ -1,5 +1,4 @@ from django.db import transaction -from django.template import RequestContext from django.views.generic import View from boards import utils diff --git a/boards/views/notifications.py b/boards/views/notifications.py new file mode 100644 --- /dev/null +++ b/boards/views/notifications.py @@ -0,0 +1,44 @@ +from django.shortcuts import render +from boards.abstracts.paginator import get_paginator +from boards.abstracts.settingsmanager import get_settings_manager, \ + SETTING_USERNAME, SETTING_LAST_NOTIFICATION_ID +from boards.models import Post +from boards.models.user import Notification +from boards.views.base import BaseBoardView + +TEMPLATE = 'boards/notifications.html' +PARAM_PAGE = 'page' +PARAM_USERNAME = 'notification_username' +REQUEST_PAGE = 'page' +RESULTS_PER_PAGE = 10 + + +class NotificationView(BaseBoardView): + + def get(self, request, username): + params = self.get_context_data() + + settings_manager = get_settings_manager(request) + + # If we open our notifications, reset the "new" count + my_username = settings_manager.get_setting(SETTING_USERNAME) + + notification_username = username.lower() + + posts = Notification.objects.get_notification_posts( + username=notification_username) + if notification_username == my_username: + last = posts.first() + if last is not None: + last_id = last.id + settings_manager.set_setting(SETTING_LAST_NOTIFICATION_ID, + last_id) + + paginator = get_paginator(posts, RESULTS_PER_PAGE) + + page = int(request.GET.get(REQUEST_PAGE, '1')) + + params[PARAM_PAGE] = paginator.page(page) + params[PARAM_USERNAME] = notification_username + + return render(request, TEMPLATE, params) diff --git a/boards/views/settings.py b/boards/views/settings.py --- a/boards/views/settings.py +++ b/boards/views/settings.py @@ -1,31 +1,37 @@ from django.db import transaction from django.shortcuts import render, redirect -from boards.abstracts.settingsmanager import get_settings_manager +from boards.abstracts.settingsmanager import get_settings_manager, \ + SETTING_USERNAME, SETTING_LAST_NOTIFICATION_ID from boards.views.base import BaseBoardView, CONTEXT_FORM from boards.forms import SettingsForm, PlainErrorList FORM_THEME = 'theme' +FORM_USERNAME = 'username' CONTEXT_HIDDEN_TAGS = 'hidden_tags' +TEMPLATE = 'boards/settings.html' + class SettingsView(BaseBoardView): def get(self, request): - params = self.get_context_data() + params = dict() settings_manager = get_settings_manager(request) selected_theme = settings_manager.get_theme() - form = SettingsForm(initial={FORM_THEME: selected_theme}, - error_class=PlainErrorList) + form = SettingsForm( + initial={ + FORM_THEME: selected_theme, + FORM_USERNAME: settings_manager.get_setting(SETTING_USERNAME)}, + error_class=PlainErrorList) params[CONTEXT_FORM] = form params[CONTEXT_HIDDEN_TAGS] = settings_manager.get_hidden_tags() - # TODO Use dict here - return render(request, 'boards/settings.html', params) + return render(request, TEMPLATE, params) def post(self, request): settings_manager = get_settings_manager(request) @@ -35,7 +41,19 @@ class SettingsView(BaseBoardView): if form.is_valid(): selected_theme = form.cleaned_data[FORM_THEME] + username = form.cleaned_data[FORM_USERNAME].lower() settings_manager.set_theme(selected_theme) - return redirect('settings') + settings_manager.set_setting(SETTING_USERNAME, username) + settings_manager.set_setting(SETTING_LAST_NOTIFICATION_ID, None) + + return redirect('settings') + else: + params = dict() + + params[CONTEXT_FORM] = form + params[CONTEXT_HIDDEN_TAGS] = settings_manager.get_hidden_tags() + + return render(request, TEMPLATE, params) + diff --git a/boards/views/tag_threads.py b/boards/views/tag_threads.py --- a/boards/views/tag_threads.py +++ b/boards/views/tag_threads.py @@ -1,14 +1,16 @@ from django.shortcuts import get_object_or_404 -from boards.abstracts.settingsmanager import get_settings_manager +from boards.abstracts.settingsmanager import get_settings_manager, \ + SETTING_FAVORITE_TAGS, SETTING_HIDDEN_TAGS from boards.models import Tag, Thread from boards.views.all_threads import AllThreadsView, DEFAULT_PAGE from boards.views.mixins import DispatcherMixin, RedirectNextMixin from boards.forms import ThreadForm, PlainErrorList PARAM_HIDDEN_TAGS = 'hidden_tags' -PARAM_FAV_TAGS = 'fav_tags' PARAM_TAG = 'tag' +PARAM_IS_FAVORITE = 'is_favorite' +PARAM_IS_HIDDEN = 'is_hidden' __author__ = 'neko259' @@ -30,8 +32,11 @@ class TagView(AllThreadsView, Dispatcher tag = get_object_or_404(Tag, name=self.tag_name) params[PARAM_TAG] = tag - params[PARAM_FAV_TAGS] = settings_manager.get_fav_tags() - params[PARAM_HIDDEN_TAGS] = settings_manager.get_hidden_tags() + fav_tag_names = settings_manager.get_setting(SETTING_FAVORITE_TAGS) + hidden_tag_names = settings_manager.get_setting(SETTING_HIDDEN_TAGS) + + params[PARAM_IS_FAVORITE] = fav_tag_names is not None and tag.name in fav_tag_names + params[PARAM_IS_HIDDEN] = hidden_tag_names is not None and tag.name in hidden_tag_names return params diff --git a/boards/views/thread.py b/boards/views/thread.py deleted file mode 100644 --- a/boards/views/thread.py +++ /dev/null @@ -1,142 +0,0 @@ -from django.core.urlresolvers import reverse -from django.db import transaction -from django.http import Http404 -from django.shortcuts import get_object_or_404, render, redirect -from django.views.generic.edit import FormMixin - -from boards import utils, settings -from boards.forms import PostForm, PlainErrorList -from boards.models import Post, Ban -from boards.views.banned import BannedView -from boards.views.base import BaseBoardView, CONTEXT_FORM -from boards.views.posting_mixin import PostMixin -import neboard - -TEMPLATE_GALLERY = 'boards/thread_gallery.html' -TEMPLATE_NORMAL = 'boards/thread.html' - -CONTEXT_POSTS = 'posts' -CONTEXT_OP = 'opening_post' -CONTEXT_BUMPLIMIT_PRG = 'bumplimit_progress' -CONTEXT_POSTS_LEFT = 'posts_left' -CONTEXT_LASTUPDATE = "last_update" -CONTEXT_MAX_REPLIES = 'max_replies' -CONTEXT_THREAD = 'thread' -CONTEXT_BUMPABLE = 'bumpable' -CONTEXT_WS_TOKEN = 'ws_token' -CONTEXT_WS_PROJECT = 'ws_project' -CONTEXT_WS_HOST = 'ws_host' -CONTEXT_WS_PORT = 'ws_port' - -FORM_TITLE = 'title' -FORM_TEXT = 'text' -FORM_IMAGE = 'image' - -MODE_GALLERY = 'gallery' -MODE_NORMAL = 'normal' - - -class ThreadView(BaseBoardView, PostMixin, FormMixin): - - def get(self, request, post_id, mode=MODE_NORMAL, form=None): - try: - opening_post = Post.objects.filter(id=post_id).only('thread_new')[0] - except IndexError: - raise Http404 - - # If this is not OP, don't show it as it is - if not opening_post or not opening_post.is_opening(): - raise Http404 - - if not form: - form = PostForm(error_class=PlainErrorList) - - thread_to_show = opening_post.get_thread() - - params = dict() - - params[CONTEXT_FORM] = form - params[CONTEXT_LASTUPDATE] = str(utils.datetime_to_epoch( - thread_to_show.last_edit_time)) - params[CONTEXT_THREAD] = thread_to_show - params[CONTEXT_MAX_REPLIES] = settings.MAX_POSTS_PER_THREAD - - if settings.WEBSOCKETS_ENABLED: - params[CONTEXT_WS_TOKEN] = utils.get_websocket_token( - timestamp=params[CONTEXT_LASTUPDATE]) - params[CONTEXT_WS_PROJECT] = neboard.settings.CENTRIFUGE_PROJECT_ID - params[CONTEXT_WS_HOST] = request.get_host().split(':')[0] - params[CONTEXT_WS_PORT] = neboard.settings.CENTRIFUGE_PORT - - # TODO Move this to subclasses: NormalThreadView, GalleryThreadView etc - if MODE_NORMAL == mode: - bumpable = thread_to_show.can_bump() - params[CONTEXT_BUMPABLE] = bumpable - if bumpable: - left_posts = settings.MAX_POSTS_PER_THREAD \ - - thread_to_show.get_reply_count() - params[CONTEXT_POSTS_LEFT] = left_posts - params[CONTEXT_BUMPLIMIT_PRG] = str( - float(left_posts) / settings.MAX_POSTS_PER_THREAD * 100) - - params[CONTEXT_OP] = opening_post - - document = TEMPLATE_NORMAL - elif MODE_GALLERY == mode: - params[CONTEXT_POSTS] = thread_to_show.get_replies_with_images( - view_fields_only=True) - - document = TEMPLATE_GALLERY - else: - raise Http404 - - return render(request, document, params) - - def post(self, request, post_id, mode=MODE_NORMAL): - opening_post = get_object_or_404(Post, id=post_id) - - # If this is not OP, don't show it as it is - if not opening_post.is_opening(): - raise Http404 - - if not opening_post.get_thread().archived: - form = PostForm(request.POST, request.FILES, - error_class=PlainErrorList) - form.session = request.session - - if form.is_valid(): - return self.new_post(request, form, opening_post) - if form.need_to_ban: - # Ban user because he is suspected to be a bot - self._ban_current_user(request) - - return self.get(request, post_id, mode, form) - - def new_post(self, request, form, opening_post=None, html_response=True): - """Add a new post (in thread or as a reply).""" - - ip = utils.get_client_ip(request) - - data = form.cleaned_data - - title = data[FORM_TITLE] - text = data[FORM_TEXT] - image = data.get(FORM_IMAGE) - - text = self._remove_invalid_links(text) - - post_thread = opening_post.get_thread() - - post = Post.objects.create_post(title=title, text=text, image=image, - thread=post_thread, ip=ip) - post.send_to_websocket(request) - - thread_to_show = (opening_post.id if opening_post else post.id) - - if html_response: - if opening_post: - return redirect( - reverse('thread', kwargs={'post_id': thread_to_show}) - + '#' + str(post.id)) - else: - return post diff --git a/boards/views/thread/__init__.py b/boards/views/thread/__init__.py new file mode 100644 --- /dev/null +++ b/boards/views/thread/__init__.py @@ -0,0 +1,3 @@ +from boards.views.thread.thread import ThreadView +from boards.views.thread.normal import NormalThreadView +from boards.views.thread.gallery import GalleryThreadView diff --git a/boards/views/thread/gallery.py b/boards/views/thread/gallery.py new file mode 100644 --- /dev/null +++ b/boards/views/thread/gallery.py @@ -0,0 +1,19 @@ +from boards.views.thread import ThreadView + +TEMPLATE_GALLERY = 'boards/thread_gallery.html' + +CONTEXT_POSTS = 'posts' + + +class GalleryThreadView(ThreadView): + + def get_template(self): + return TEMPLATE_GALLERY + + def get_data(self, thread): + params = dict() + + params[CONTEXT_POSTS] = thread.get_replies_with_images( + view_fields_only=True) + + return params diff --git a/boards/views/thread/normal.py b/boards/views/thread/normal.py new file mode 100644 --- /dev/null +++ b/boards/views/thread/normal.py @@ -0,0 +1,38 @@ +from boards import settings +from boards.views.thread import ThreadView + +TEMPLATE_NORMAL = 'boards/thread.html' + +CONTEXT_OP = 'opening_post' +CONTEXT_BUMPLIMIT_PRG = 'bumplimit_progress' +CONTEXT_POSTS_LEFT = 'posts_left' +CONTEXT_BUMPABLE = 'bumpable' + +FORM_TITLE = 'title' +FORM_TEXT = 'text' +FORM_IMAGE = 'image' + +MODE_GALLERY = 'gallery' +MODE_NORMAL = 'normal' + + +class NormalThreadView(ThreadView): + + def get_template(self): + return TEMPLATE_NORMAL + + def get_data(self, thread): + params = dict() + + bumpable = thread.can_bump() + params[CONTEXT_BUMPABLE] = bumpable + if bumpable: + left_posts = settings.MAX_POSTS_PER_THREAD \ + - thread.get_reply_count() + params[CONTEXT_POSTS_LEFT] = left_posts + params[CONTEXT_BUMPLIMIT_PRG] = str( + float(left_posts) / settings.MAX_POSTS_PER_THREAD * 100) + + params[CONTEXT_OP] = thread.get_opening_post() + + return params diff --git a/boards/views/thread/thread.py b/boards/views/thread/thread.py new file mode 100644 --- /dev/null +++ b/boards/views/thread/thread.py @@ -0,0 +1,133 @@ +from django.core.urlresolvers import reverse +from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction +from django.http import Http404 +from django.shortcuts import get_object_or_404, render, redirect +from django.views.generic.edit import FormMixin + +from boards import utils, settings +from boards.forms import PostForm, PlainErrorList +from boards.models import Post, Ban +from boards.views.banned import BannedView +from boards.views.base import BaseBoardView, CONTEXT_FORM +from boards.views.posting_mixin import PostMixin +import neboard + +TEMPLATE_GALLERY = 'boards/thread_gallery.html' +TEMPLATE_NORMAL = 'boards/thread.html' + +CONTEXT_POSTS = 'posts' +CONTEXT_OP = 'opening_post' +CONTEXT_BUMPLIMIT_PRG = 'bumplimit_progress' +CONTEXT_POSTS_LEFT = 'posts_left' +CONTEXT_LASTUPDATE = "last_update" +CONTEXT_MAX_REPLIES = 'max_replies' +CONTEXT_THREAD = 'thread' +CONTEXT_BUMPABLE = 'bumpable' +CONTEXT_WS_TOKEN = 'ws_token' +CONTEXT_WS_PROJECT = 'ws_project' +CONTEXT_WS_HOST = 'ws_host' +CONTEXT_WS_PORT = 'ws_port' + +FORM_TITLE = 'title' +FORM_TEXT = 'text' +FORM_IMAGE = 'image' + + +class ThreadView(BaseBoardView, PostMixin, FormMixin): + + def get(self, request, post_id, form: PostForm=None): + try: + opening_post = Post.objects.get(id=post_id) + except ObjectDoesNotExist: + raise Http404 + + # If this is not OP, don't show it as it is + if not opening_post.is_opening(): + return redirect(opening_post.get_thread().get_opening_post().get_url()) + + if not form: + form = PostForm(error_class=PlainErrorList) + + thread_to_show = opening_post.get_thread() + + params = dict() + + params[CONTEXT_FORM] = form + params[CONTEXT_LASTUPDATE] = str(utils.datetime_to_epoch( + thread_to_show.last_edit_time)) + params[CONTEXT_THREAD] = thread_to_show + params[CONTEXT_MAX_REPLIES] = settings.MAX_POSTS_PER_THREAD + + if settings.WEBSOCKETS_ENABLED: + params[CONTEXT_WS_TOKEN] = utils.get_websocket_token( + timestamp=params[CONTEXT_LASTUPDATE]) + params[CONTEXT_WS_PROJECT] = neboard.settings.CENTRIFUGE_PROJECT_ID + params[CONTEXT_WS_HOST] = request.get_host().split(':')[0] + params[CONTEXT_WS_PORT] = neboard.settings.CENTRIFUGE_PORT + + params.update(self.get_data(thread_to_show)) + + return render(request, self.get_template(), params) + + def post(self, request, post_id): + opening_post = get_object_or_404(Post, id=post_id) + + # If this is not OP, don't show it as it is + if not opening_post.is_opening(): + raise Http404 + + if not opening_post.get_thread().archived: + form = PostForm(request.POST, request.FILES, + error_class=PlainErrorList) + form.session = request.session + + if form.is_valid(): + return self.new_post(request, form, opening_post) + if form.need_to_ban: + # Ban user because he is suspected to be a bot + self._ban_current_user(request) + + return self.get(request, post_id, form) + + def new_post(self, request, form: PostForm, opening_post: Post=None, + html_response=True): + """ + Adds a new post (in thread or as a reply). + """ + + ip = utils.get_client_ip(request) + + data = form.cleaned_data + + title = data[FORM_TITLE] + text = data[FORM_TEXT] + image = form.get_image() + + text = self._remove_invalid_links(text) + + post_thread = opening_post.get_thread() + + post = Post.objects.create_post(title=title, text=text, image=image, + thread=post_thread, ip=ip) + post.send_to_websocket(request) + + if html_response: + if opening_post: + return redirect(post.get_url()) + else: + return post + + def get_data(self, thread): + """ + Returns context params for the view. + """ + + pass + + def get_template(self): + """ + Gets template to show the thread mode on. + """ + + pass diff --git a/changelog.markdown b/changelog.markdown --- a/changelog.markdown +++ b/changelog.markdown @@ -41,3 +41,37 @@ images to a post * Changed markdown to bbcode * Removed linked tags * [ADMIN] Added title to the post logs to make them more informative + +# 2.2 +* Support websockets for thread update +* Using div as line separator +* CSS and JS compressor + +# 2.2.1 +* Changed logs style +* Text preparsing. Support markdown-style text that parses into bbcode +* "bumpable" field for threads. If the thread became non-bumpable, it will +remain in this state even if the bumplimit changes or posts will be deleted from +it + +# 2.2.4 +* Default settings. There is a global neboard default settings file, but user +can override settings in the old settings.py +* Required tags. Some tags can be marked as required and you can't create thread +without at least one of them, while you can add any tags you want to it +* [ADMIN] Cosmetic changes in the admin site. Adding and removing tags is much +more simple now +* Don't save tag's threads as a separate table, use aggregation instead + +# 2.3.0 Neiro +* Image deduplication + +# 2.4.0 Korra +* Downloading images by URL +* [CODE] Cached properties + +# 2.5.0 Yasako +* User notifications +* Posting to many threads by one post +* Tag details +* Removed compact form diff --git a/docs/api.markdown b/docs/api.markdown --- a/docs/api.markdown +++ b/docs/api.markdown @@ -45,6 +45,15 @@ format. 2 formats are available: ``html` * ``updated``: list of updated posts * ``last_update``: last update timestamp +## Notifications ## + + /api/notifications//[?last=] + +Get user notifications for user starting from the post ID. + +* ``username``: name of the notified user +* ``id``: ID of a last notification post + ## General info ## In case of incorrect request you can get http error 404.