##// END OF EJS Templates
Merged with default branch
neko259 -
r1030:61a1453b merge decentral
parent child Browse files
Show More
@@ -1,27 +1,28 b''
1 bc8fce57a613175450b8b6d933cdd85f22c04658 1.1
1 bc8fce57a613175450b8b6d933cdd85f22c04658 1.1
2 784258eb652c563c288ca7652c33f52cd4733d83 1.1-stable
2 784258eb652c563c288ca7652c33f52cd4733d83 1.1-stable
3 1b53a22467a8fccc798935d7a26efe78e4bc7b25 1.2-stable
3 1b53a22467a8fccc798935d7a26efe78e4bc7b25 1.2-stable
4 1713fb7543386089e364c39703b79e57d3d851f0 1.3
4 1713fb7543386089e364c39703b79e57d3d851f0 1.3
5 80f183ebbe132ea8433eacae9431360f31fe7083 1.4
5 80f183ebbe132ea8433eacae9431360f31fe7083 1.4
6 4330ff5a2bf6c543d8aaae8a43de1dc062f3bd13 1.4.1
6 4330ff5a2bf6c543d8aaae8a43de1dc062f3bd13 1.4.1
7 8531d7b001392289a6b761f38c73a257606552ad 1.5
7 8531d7b001392289a6b761f38c73a257606552ad 1.5
8 78e843c8b04b5a81cee5aa24601e305fae75da24 1.5.1
8 78e843c8b04b5a81cee5aa24601e305fae75da24 1.5.1
9 4f92838730ed9aa1d17651bbcdca19a097fd0c37 1.6
9 4f92838730ed9aa1d17651bbcdca19a097fd0c37 1.6
10 4bac2f37ea463337ddd27f98e7985407a74de504 1.7
10 4bac2f37ea463337ddd27f98e7985407a74de504 1.7
11 1c4febea92c6503ae557fba73b2768659ae90d24 1.7.1
11 1c4febea92c6503ae557fba73b2768659ae90d24 1.7.1
12 56a4a4578fc454ee455e33dd74a2cc82234bcb59 1.7.2
12 56a4a4578fc454ee455e33dd74a2cc82234bcb59 1.7.2
13 34d6f3d5deb22be56b6c1512ec654bd7f6e03bcc 1.7.3
13 34d6f3d5deb22be56b6c1512ec654bd7f6e03bcc 1.7.3
14 f5cca33d29c673b67d43f310bebc4e3a21c6d04c 1.7.4
14 f5cca33d29c673b67d43f310bebc4e3a21c6d04c 1.7.4
15 7f7c33ba6e3f3797ca866c5ed5d358a2393f1371 1.8
15 7f7c33ba6e3f3797ca866c5ed5d358a2393f1371 1.8
16 a6b9dd9547bdc17b681502efcceb17aa5c09adf4 1.8.1
16 a6b9dd9547bdc17b681502efcceb17aa5c09adf4 1.8.1
17 8318fa1615d1946e4519f5735ae880909521990d 2.0
17 8318fa1615d1946e4519f5735ae880909521990d 2.0
18 e23590ee7e2067a3f0fc3cbcfd66404b47127feb 2.1
18 e23590ee7e2067a3f0fc3cbcfd66404b47127feb 2.1
19 4d998aba79e4abf0a2e78e93baaa2c2800b1c49c 2.2
19 4d998aba79e4abf0a2e78e93baaa2c2800b1c49c 2.2
20 07fdef4ac33a859250d03f17c594089792bca615 2.2.1
20 07fdef4ac33a859250d03f17c594089792bca615 2.2.1
21 bcc74d45f060ecd3ff06ff448165aea0d026cb3e 2.2.2
21 bcc74d45f060ecd3ff06ff448165aea0d026cb3e 2.2.2
22 b0e629ff24eb47a449ecfb455dc6cc600d18c9e2 2.2.3
22 b0e629ff24eb47a449ecfb455dc6cc600d18c9e2 2.2.3
23 1b52ba60f17fd7c90912c14d9d17e880b7952d01 2.2.4
23 1b52ba60f17fd7c90912c14d9d17e880b7952d01 2.2.4
24 957e2fec91468f739b0fc2b9936d564505048c68 2.3.0
24 957e2fec91468f739b0fc2b9936d564505048c68 2.3.0
25 bb91141c6ea5c822ccbe2d46c3c48bdab683b77d 2.4.0
25 bb91141c6ea5c822ccbe2d46c3c48bdab683b77d 2.4.0
26 97eb184637e5691b288eaf6b03e8971f3364c239 2.5.0
26 97eb184637e5691b288eaf6b03e8971f3364c239 2.5.0
27 119fafc5381b933bf30d97be0b278349f6135075 2.5.1
27 119fafc5381b933bf30d97be0b278349f6135075 2.5.1
28 d528d76d3242cced614fa11bb63f3d342e4e1d09 2.5.2
@@ -1,65 +1,68 b''
1 from boards.abstracts.settingsmanager import get_settings_manager, \
1 from boards.abstracts.settingsmanager import get_settings_manager, \
2 SETTING_USERNAME, SETTING_LAST_NOTIFICATION_ID
2 SETTING_USERNAME, SETTING_LAST_NOTIFICATION_ID
3 from boards.models.user import Notification
3 from boards.models.user import Notification
4
4
5 __author__ = 'neko259'
5 __author__ = 'neko259'
6
6
7 from boards import settings
7 from boards import settings
8 from boards.models import Post
8 from boards.models import Post, Tag
9
9
10 CONTEXT_SITE_NAME = 'site_name'
10 CONTEXT_SITE_NAME = 'site_name'
11 CONTEXT_VERSION = 'version'
11 CONTEXT_VERSION = 'version'
12 CONTEXT_MODERATOR = 'moderator'
12 CONTEXT_MODERATOR = 'moderator'
13 CONTEXT_THEME_CSS = 'theme_css'
13 CONTEXT_THEME_CSS = 'theme_css'
14 CONTEXT_THEME = 'theme'
14 CONTEXT_THEME = 'theme'
15 CONTEXT_PPD = 'posts_per_day'
15 CONTEXT_PPD = 'posts_per_day'
16 CONTEXT_TAGS = 'tags'
16 CONTEXT_TAGS = 'tags'
17 CONTEXT_USER = 'user'
17 CONTEXT_USER = 'user'
18 CONTEXT_NEW_NOTIFICATIONS_COUNT = 'new_notifications_count'
18 CONTEXT_NEW_NOTIFICATIONS_COUNT = 'new_notifications_count'
19 CONTEXT_USERNAME = 'username'
19 CONTEXT_USERNAME = 'username'
20 CONTEXT_TAGS_STR = 'tags_str'
20
21
21 PERMISSION_MODERATE = 'moderation'
22 PERMISSION_MODERATE = 'moderation'
22
23
23
24
24 def get_notifications(context, request):
25 def get_notifications(context, request):
25 settings_manager = get_settings_manager(request)
26 settings_manager = get_settings_manager(request)
26 username = settings_manager.get_setting(SETTING_USERNAME)
27 username = settings_manager.get_setting(SETTING_USERNAME)
27 new_notifications_count = 0
28 new_notifications_count = 0
28 if username is not None and len(username) > 0:
29 if username is not None and len(username) > 0:
29 last_notification_id = settings_manager.get_setting(
30 last_notification_id = settings_manager.get_setting(
30 SETTING_LAST_NOTIFICATION_ID)
31 SETTING_LAST_NOTIFICATION_ID)
31
32
32 new_notifications_count = Notification.objects.get_notification_posts(
33 new_notifications_count = Notification.objects.get_notification_posts(
33 username=username, last=last_notification_id).count()
34 username=username, last=last_notification_id).count()
34 context[CONTEXT_NEW_NOTIFICATIONS_COUNT] = new_notifications_count
35 context[CONTEXT_NEW_NOTIFICATIONS_COUNT] = new_notifications_count
35 context[CONTEXT_USERNAME] = username
36 context[CONTEXT_USERNAME] = username
36
37
37
38
38 def get_moderator_permissions(context, request):
39 def get_moderator_permissions(context, request):
39 try:
40 try:
40 moderate = request.user.has_perm(PERMISSION_MODERATE)
41 moderate = request.user.has_perm(PERMISSION_MODERATE)
41 except AttributeError:
42 except AttributeError:
42 moderate = False
43 moderate = False
43 context[CONTEXT_MODERATOR] = moderate
44 context[CONTEXT_MODERATOR] = moderate
44
45
45
46
46 def user_and_ui_processor(request):
47 def user_and_ui_processor(request):
47 context = dict()
48 context = dict()
48
49
49 context[CONTEXT_PPD] = float(Post.objects.get_posts_per_day())
50 context[CONTEXT_PPD] = float(Post.objects.get_posts_per_day())
50
51
51 settings_manager = get_settings_manager(request)
52 settings_manager = get_settings_manager(request)
52 context[CONTEXT_TAGS] = settings_manager.get_fav_tags()
53 fav_tags = settings_manager.get_fav_tags()
54 context[CONTEXT_TAGS] = fav_tags
55 context[CONTEXT_TAGS_STR] = Tag.objects.get_tag_url_list(fav_tags)
53 theme = settings_manager.get_theme()
56 theme = settings_manager.get_theme()
54 context[CONTEXT_THEME] = theme
57 context[CONTEXT_THEME] = theme
55 context[CONTEXT_THEME_CSS] = 'css/' + theme + '/base_page.css'
58 context[CONTEXT_THEME_CSS] = 'css/' + theme + '/base_page.css'
56
59
57 # This shows the moderator panel
60 # This shows the moderator panel
58 get_moderator_permissions(context, request)
61 get_moderator_permissions(context, request)
59
62
60 context[CONTEXT_VERSION] = settings.VERSION
63 context[CONTEXT_VERSION] = settings.VERSION
61 context[CONTEXT_SITE_NAME] = settings.SITE_NAME
64 context[CONTEXT_SITE_NAME] = settings.SITE_NAME
62
65
63 get_notifications(context, request)
66 get_notifications(context, request)
64
67
65 return context
68 return context
@@ -1,22 +1,22 b''
1 VERSION = '2.5.1 Yasako'
1 VERSION = '2.5.2 Yasako'
2 SITE_NAME = 'Neboard'
2 SITE_NAME = 'Neboard'
3
3
4 CACHE_TIMEOUT = 600 # Timeout for caching, if cache is used
4 CACHE_TIMEOUT = 600 # Timeout for caching, if cache is used
5 LOGIN_TIMEOUT = 3600 # Timeout between login tries
5 LOGIN_TIMEOUT = 3600 # Timeout between login tries
6 MAX_TEXT_LENGTH = 30000 # Max post length in characters
6 MAX_TEXT_LENGTH = 30000 # Max post length in characters
7 MAX_IMAGE_SIZE = 8 * 1024 * 1024 # Max image size
7 MAX_IMAGE_SIZE = 8 * 1024 * 1024 # Max image size
8
8
9 # Thread bumplimit
9 # Thread bumplimit
10 MAX_POSTS_PER_THREAD = 10
10 MAX_POSTS_PER_THREAD = 10
11 # Old posts will be archived or deleted if this value is reached
11 # Old posts will be archived or deleted if this value is reached
12 MAX_THREAD_COUNT = 5
12 MAX_THREAD_COUNT = 5
13 THREADS_PER_PAGE = 3
13 THREADS_PER_PAGE = 3
14 DEFAULT_THEME = 'md'
14 DEFAULT_THEME = 'md'
15 LAST_REPLIES_COUNT = 3
15 LAST_REPLIES_COUNT = 3
16
16
17 # Enable archiving threads instead of deletion when the thread limit is reached
17 # Enable archiving threads instead of deletion when the thread limit is reached
18 ARCHIVE_THREADS = True
18 ARCHIVE_THREADS = True
19 # Limit posting speed
19 # Limit posting speed
20 LIMIT_POSTING_SPEED = False
20 LIMIT_POSTING_SPEED = False
21 # Thread update
21 # Thread update
22 WEBSOCKETS_ENABLED = True
22 WEBSOCKETS_ENABLED = True
@@ -1,346 +1,338 b''
1 import re
1 import re
2 import time
2 import time
3
3
4 from django import forms
4 from django import forms
5 from django.core.files.uploadedfile import SimpleUploadedFile
5 from django.core.files.uploadedfile import SimpleUploadedFile
6 from django.forms.util import ErrorList
6 from django.forms.util import ErrorList
7 from django.utils.translation import ugettext_lazy as _
7 from django.utils.translation import ugettext_lazy as _
8 import requests
8 import requests
9
9
10 from boards.mdx_neboard import formatters
10 from boards.mdx_neboard import formatters
11 from boards.models.post import TITLE_MAX_LENGTH
11 from boards.models.post import TITLE_MAX_LENGTH
12 from boards.models import Tag
12 from boards.models import Tag
13 from neboard import settings
13 from neboard import settings
14 import boards.settings as board_settings
14 import boards.settings as board_settings
15
15
16
16
17 CONTENT_TYPE_IMAGE = (
17 CONTENT_TYPE_IMAGE = (
18 'image/jpeg',
18 'image/jpeg',
19 'image/png',
19 'image/png',
20 'image/gif',
20 'image/gif',
21 'image/bmp',
21 'image/bmp',
22 )
22 )
23
23
24 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
24 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
25
25
26 VETERAN_POSTING_DELAY = 5
26 VETERAN_POSTING_DELAY = 5
27
27
28 ATTRIBUTE_PLACEHOLDER = 'placeholder'
28 ATTRIBUTE_PLACEHOLDER = 'placeholder'
29 ATTRIBUTE_ROWS = 'rows'
29 ATTRIBUTE_ROWS = 'rows'
30
30
31 LAST_POST_TIME = 'last_post_time'
31 LAST_POST_TIME = 'last_post_time'
32 LAST_LOGIN_TIME = 'last_login_time'
32 LAST_LOGIN_TIME = 'last_login_time'
33 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
33 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
34 TAGS_PLACEHOLDER = _('tag1 several_words_tag')
34 TAGS_PLACEHOLDER = _('tag1 several_words_tag')
35
35
36 LABEL_TITLE = _('Title')
36 LABEL_TITLE = _('Title')
37 LABEL_TEXT = _('Text')
37 LABEL_TEXT = _('Text')
38 LABEL_TAG = _('Tag')
38 LABEL_TAG = _('Tag')
39 LABEL_SEARCH = _('Search')
39 LABEL_SEARCH = _('Search')
40
40
41 TAG_MAX_LENGTH = 20
41 TAG_MAX_LENGTH = 20
42
42
43 IMAGE_DOWNLOAD_CHUNK_BYTES = 100000
43 IMAGE_DOWNLOAD_CHUNK_BYTES = 100000
44
44
45 HTTP_RESULT_OK = 200
45 HTTP_RESULT_OK = 200
46
46
47 TEXTAREA_ROWS = 4
47 TEXTAREA_ROWS = 4
48
48
49
49
50 class FormatPanel(forms.Textarea):
50 class FormatPanel(forms.Textarea):
51 """
51 """
52 Panel for text formatting. Consists of buttons to add different tags to the
52 Panel for text formatting. Consists of buttons to add different tags to the
53 form text area.
53 form text area.
54 """
54 """
55
55
56 def render(self, name, value, attrs=None):
56 def render(self, name, value, attrs=None):
57 output = '<div id="mark-panel">'
57 output = '<div id="mark-panel">'
58 for formatter in formatters:
58 for formatter in formatters:
59 output += '<span class="mark_btn"' + \
59 output += '<span class="mark_btn"' + \
60 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
60 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
61 '\', \'' + formatter.format_right + '\')">' + \
61 '\', \'' + formatter.format_right + '\')">' + \
62 formatter.preview_left + formatter.name + \
62 formatter.preview_left + formatter.name + \
63 formatter.preview_right + '</span>'
63 formatter.preview_right + '</span>'
64
64
65 output += '</div>'
65 output += '</div>'
66 output += super(FormatPanel, self).render(name, value, attrs=None)
66 output += super(FormatPanel, self).render(name, value, attrs=None)
67
67
68 return output
68 return output
69
69
70
70
71 class PlainErrorList(ErrorList):
71 class PlainErrorList(ErrorList):
72 def __unicode__(self):
72 def __unicode__(self):
73 return self.as_text()
73 return self.as_text()
74
74
75 def as_text(self):
75 def as_text(self):
76 return ''.join(['(!) %s ' % e for e in self])
76 return ''.join(['(!) %s ' % e for e in self])
77
77
78
78
79 class NeboardForm(forms.Form):
79 class NeboardForm(forms.Form):
80 """
80 """
81 Form with neboard-specific formatting.
81 Form with neboard-specific formatting.
82 """
82 """
83
83
84 def as_div(self):
84 def as_div(self):
85 """
85 """
86 Returns this form rendered as HTML <as_div>s.
86 Returns this form rendered as HTML <as_div>s.
87 """
87 """
88
88
89 return self._html_output(
89 return self._html_output(
90 # TODO Do not show hidden rows in the list here
90 # TODO Do not show hidden rows in the list here
91 normal_row='<div class="form-row"><div class="form-label">'
91 normal_row='<div class="form-row"><div class="form-label">'
92 '%(label)s'
92 '%(label)s'
93 '</div></div>'
93 '</div></div>'
94 '<div class="form-row"><div class="form-input">'
94 '<div class="form-row"><div class="form-input">'
95 '%(field)s'
95 '%(field)s'
96 '</div></div>'
96 '</div></div>'
97 '<div class="form-row">'
97 '<div class="form-row">'
98 '%(help_text)s'
98 '%(help_text)s'
99 '</div>',
99 '</div>',
100 error_row='<div class="form-row">'
100 error_row='<div class="form-row">'
101 '<div class="form-label"></div>'
101 '<div class="form-label"></div>'
102 '<div class="form-errors">%s</div>'
102 '<div class="form-errors">%s</div>'
103 '</div>',
103 '</div>',
104 row_ender='</div>',
104 row_ender='</div>',
105 help_text_html='%s',
105 help_text_html='%s',
106 errors_on_separate_row=True)
106 errors_on_separate_row=True)
107
107
108 def as_json_errors(self):
108 def as_json_errors(self):
109 errors = []
109 errors = []
110
110
111 for name, field in list(self.fields.items()):
111 for name, field in list(self.fields.items()):
112 if self[name].errors:
112 if self[name].errors:
113 errors.append({
113 errors.append({
114 'field': name,
114 'field': name,
115 'errors': self[name].errors.as_text(),
115 'errors': self[name].errors.as_text(),
116 })
116 })
117
117
118 return errors
118 return errors
119
119
120
120
121 class PostForm(NeboardForm):
121 class PostForm(NeboardForm):
122
122
123 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
123 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
124 label=LABEL_TITLE)
124 label=LABEL_TITLE)
125 text = forms.CharField(
125 text = forms.CharField(
126 widget=FormatPanel(attrs={
126 widget=FormatPanel(attrs={
127 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
127 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
128 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
128 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
129 }),
129 }),
130 required=False, label=LABEL_TEXT)
130 required=False, label=LABEL_TEXT)
131 image = forms.ImageField(required=False, label=_('Image'),
131 image = forms.ImageField(required=False, label=_('Image'),
132 widget=forms.ClearableFileInput(
132 widget=forms.ClearableFileInput(
133 attrs={'accept': 'image/*'}))
133 attrs={'accept': 'image/*'}))
134 image_url = forms.CharField(required=False, label=_('Image URL'),
134 image_url = forms.CharField(required=False, label=_('Image URL'),
135 widget=forms.TextInput(
135 widget=forms.TextInput(
136 attrs={ATTRIBUTE_PLACEHOLDER:
136 attrs={ATTRIBUTE_PLACEHOLDER:
137 'http://example.com/image.png'}))
137 'http://example.com/image.png'}))
138
138
139 # This field is for spam prevention only
139 # This field is for spam prevention only
140 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
140 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
141 widget=forms.TextInput(attrs={
141 widget=forms.TextInput(attrs={
142 'class': 'form-email'}))
142 'class': 'form-email'}))
143
143
144 session = None
144 session = None
145 need_to_ban = False
145 need_to_ban = False
146
146
147 def clean_title(self):
147 def clean_title(self):
148 title = self.cleaned_data['title']
148 title = self.cleaned_data['title']
149 if title:
149 if title:
150 if len(title) > TITLE_MAX_LENGTH:
150 if len(title) > TITLE_MAX_LENGTH:
151 raise forms.ValidationError(_('Title must have less than %s '
151 raise forms.ValidationError(_('Title must have less than %s '
152 'characters') %
152 'characters') %
153 str(TITLE_MAX_LENGTH))
153 str(TITLE_MAX_LENGTH))
154 return title
154 return title
155
155
156 def clean_text(self):
156 def clean_text(self):
157 text = self.cleaned_data['text'].strip()
157 text = self.cleaned_data['text'].strip()
158 if text:
158 if text:
159 if len(text) > board_settings.MAX_TEXT_LENGTH:
159 if len(text) > board_settings.MAX_TEXT_LENGTH:
160 raise forms.ValidationError(_('Text must have less than %s '
160 raise forms.ValidationError(_('Text must have less than %s '
161 'characters') %
161 'characters') %
162 str(board_settings
162 str(board_settings
163 .MAX_TEXT_LENGTH))
163 .MAX_TEXT_LENGTH))
164 return text
164 return text
165
165
166 def clean_image(self):
166 def clean_image(self):
167 image = self.cleaned_data['image']
167 image = self.cleaned_data['image']
168
168
169 self._validate_image(image)
169 if image:
170 self.validate_image_size(image.size)
170
171
171 return image
172 return image
172
173
173 def clean_image_url(self):
174 def clean_image_url(self):
174 url = self.cleaned_data['image_url']
175 url = self.cleaned_data['image_url']
175
176
176 image = None
177 image = None
177 if url:
178 if url:
178 image = self._get_image_from_url(url)
179 image = self._get_image_from_url(url)
179
180
180 if not image:
181 if not image:
181 raise forms.ValidationError(_('Invalid URL'))
182 raise forms.ValidationError(_('Invalid URL'))
182
183 else:
183 self._validate_image(image)
184 self.validate_image_size(image.size)
184
185
185 return image
186 return image
186
187
187 def clean(self):
188 def clean(self):
188 cleaned_data = super(PostForm, self).clean()
189 cleaned_data = super(PostForm, self).clean()
189
190
190 if not self.session:
191 if not self.session:
191 raise forms.ValidationError('Humans have sessions')
192 raise forms.ValidationError('Humans have sessions')
192
193
193 if cleaned_data['email']:
194 if cleaned_data['email']:
194 self.need_to_ban = True
195 self.need_to_ban = True
195 raise forms.ValidationError('A human cannot enter a hidden field')
196 raise forms.ValidationError('A human cannot enter a hidden field')
196
197
197 if not self.errors:
198 if not self.errors:
198 self._clean_text_image()
199 self._clean_text_image()
199
200
200 if not self.errors and self.session:
201 if not self.errors and self.session:
201 self._validate_posting_speed()
202 self._validate_posting_speed()
202
203
203 return cleaned_data
204 return cleaned_data
204
205
205 def get_image(self):
206 def get_image(self):
206 """
207 """
207 Gets image from file or URL.
208 Gets image from file or URL.
208 """
209 """
209
210
210 image = self.cleaned_data['image']
211 image = self.cleaned_data['image']
211 return image if image else self.cleaned_data['image_url']
212 return image if image else self.cleaned_data['image_url']
212
213
213 def _clean_text_image(self):
214 def _clean_text_image(self):
214 text = self.cleaned_data.get('text')
215 text = self.cleaned_data.get('text')
215 image = self.get_image()
216 image = self.get_image()
216
217
217 if (not text) and (not image):
218 if (not text) and (not image):
218 error_message = _('Either text or image must be entered.')
219 error_message = _('Either text or image must be entered.')
219 self._errors['text'] = self.error_class([error_message])
220 self._errors['text'] = self.error_class([error_message])
220
221
221 def _validate_image(self, image):
222 if image:
223 if image.size > board_settings.MAX_IMAGE_SIZE:
224 raise forms.ValidationError(
225 _('Image must be less than %s bytes')
226 % str(board_settings.MAX_IMAGE_SIZE))
227
228 def _validate_posting_speed(self):
222 def _validate_posting_speed(self):
229 can_post = True
223 can_post = True
230
224
231 posting_delay = settings.POSTING_DELAY
225 posting_delay = settings.POSTING_DELAY
232
226
233 if board_settings.LIMIT_POSTING_SPEED and LAST_POST_TIME in \
227 if board_settings.LIMIT_POSTING_SPEED and LAST_POST_TIME in \
234 self.session:
228 self.session:
235 now = time.time()
229 now = time.time()
236 last_post_time = self.session[LAST_POST_TIME]
230 last_post_time = self.session[LAST_POST_TIME]
237
231
238 current_delay = int(now - last_post_time)
232 current_delay = int(now - last_post_time)
239
233
240 if current_delay < posting_delay:
234 if current_delay < posting_delay:
241 error_message = _('Wait %s seconds after last posting') % str(
235 error_message = _('Wait %s seconds after last posting') % str(
242 posting_delay - current_delay)
236 posting_delay - current_delay)
243 self._errors['text'] = self.error_class([error_message])
237 self._errors['text'] = self.error_class([error_message])
244
238
245 can_post = False
239 can_post = False
246
240
247 if can_post:
241 if can_post:
248 self.session[LAST_POST_TIME] = time.time()
242 self.session[LAST_POST_TIME] = time.time()
249
243
244 def validate_image_size(self, size: int):
245 if size > board_settings.MAX_IMAGE_SIZE:
246 raise forms.ValidationError(
247 _('Image must be less than %s bytes')
248 % str(board_settings.MAX_IMAGE_SIZE))
249
250 def _get_image_from_url(self, url: str) -> SimpleUploadedFile:
250 def _get_image_from_url(self, url: str) -> SimpleUploadedFile:
251 """
251 """
252 Gets an image file from URL.
252 Gets an image file from URL.
253 """
253 """
254
254
255 img_temp = None
255 img_temp = None
256
256
257 try:
257 try:
258 # Verify content headers
258 # Verify content headers
259 response_head = requests.head(url, verify=False)
259 response_head = requests.head(url, verify=False)
260 content_type = response_head.headers['content-type'].split(';')[0]
260 content_type = response_head.headers['content-type'].split(';')[0]
261 if content_type in CONTENT_TYPE_IMAGE:
261 if content_type in CONTENT_TYPE_IMAGE:
262 length_header = response_head.headers.get('content-length')
262 length_header = response_head.headers.get('content-length')
263 if length_header:
263 if length_header:
264 length = int(length_header)
264 length = int(length_header)
265 if length > board_settings.MAX_IMAGE_SIZE:
265 self.validate_image_size(length)
266 raise forms.ValidationError(
267 _('Image must be less than %s bytes')
268 % str(board_settings.MAX_IMAGE_SIZE))
269
270 # Get the actual content into memory
266 # Get the actual content into memory
271 response = requests.get(url, verify=False, stream=True)
267 response = requests.get(url, verify=False, stream=True)
272
268
273 # Download image, stop if the size exceeds limit
269 # Download image, stop if the size exceeds limit
274 size = 0
270 size = 0
275 content = b''
271 content = b''
276 for chunk in response.iter_content(IMAGE_DOWNLOAD_CHUNK_BYTES):
272 for chunk in response.iter_content(IMAGE_DOWNLOAD_CHUNK_BYTES):
277 size += len(chunk)
273 size += len(chunk)
278 if size > board_settings.MAX_IMAGE_SIZE:
274 self.validate_image_size(size)
279 # TODO Dedup this code into a method
280 raise forms.ValidationError(
281 _('Image must be less than %s bytes')
282 % str(board_settings.MAX_IMAGE_SIZE))
283 content += chunk
275 content += chunk
284
276
285 if response.status_code == HTTP_RESULT_OK and content:
277 if response.status_code == HTTP_RESULT_OK and content:
286 # Set a dummy file name that will be replaced
278 # Set a dummy file name that will be replaced
287 # anyway, just keep the valid extension
279 # anyway, just keep the valid extension
288 filename = 'image.' + content_type.split('/')[1]
280 filename = 'image.' + content_type.split('/')[1]
289 img_temp = SimpleUploadedFile(filename, content,
281 img_temp = SimpleUploadedFile(filename, content,
290 content_type)
282 content_type)
291 except Exception:
283 except Exception:
292 # Just return no image
284 # Just return no image
293 pass
285 pass
294
286
295 return img_temp
287 return img_temp
296
288
297
289
298 class ThreadForm(PostForm):
290 class ThreadForm(PostForm):
299
291
300 tags = forms.CharField(
292 tags = forms.CharField(
301 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
293 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
302 max_length=100, label=_('Tags'), required=True)
294 max_length=100, label=_('Tags'), required=True)
303
295
304 def clean_tags(self):
296 def clean_tags(self):
305 tags = self.cleaned_data['tags'].strip()
297 tags = self.cleaned_data['tags'].strip()
306
298
307 if not tags or not REGEX_TAGS.match(tags):
299 if not tags or not REGEX_TAGS.match(tags):
308 raise forms.ValidationError(
300 raise forms.ValidationError(
309 _('Inappropriate characters in tags.'))
301 _('Inappropriate characters in tags.'))
310
302
311 required_tag_exists = False
303 required_tag_exists = False
312 for tag in tags.split():
304 for tag in tags.split():
313 tag_model = Tag.objects.filter(name=tag.strip().lower(),
305 tag_model = Tag.objects.filter(name=tag.strip().lower(),
314 required=True)
306 required=True)
315 if tag_model.exists():
307 if tag_model.exists():
316 required_tag_exists = True
308 required_tag_exists = True
317 break
309 break
318
310
319 if not required_tag_exists:
311 if not required_tag_exists:
320 raise forms.ValidationError(_('Need at least 1 required tag.'))
312 raise forms.ValidationError(_('Need at least 1 required tag.'))
321
313
322 return tags
314 return tags
323
315
324 def clean(self):
316 def clean(self):
325 cleaned_data = super(ThreadForm, self).clean()
317 cleaned_data = super(ThreadForm, self).clean()
326
318
327 return cleaned_data
319 return cleaned_data
328
320
329
321
330 class SettingsForm(NeboardForm):
322 class SettingsForm(NeboardForm):
331
323
332 theme = forms.ChoiceField(choices=settings.THEMES,
324 theme = forms.ChoiceField(choices=settings.THEMES,
333 label=_('Theme'))
325 label=_('Theme'))
334 username = forms.CharField(label=_('User name'), required=False)
326 username = forms.CharField(label=_('User name'), required=False)
335
327
336 def clean_username(self):
328 def clean_username(self):
337 username = self.cleaned_data['username']
329 username = self.cleaned_data['username']
338
330
339 if username and not REGEX_TAGS.match(username):
331 if username and not REGEX_TAGS.match(username):
340 raise forms.ValidationError(_('Inappropriate characters.'))
332 raise forms.ValidationError(_('Inappropriate characters.'))
341
333
342 return username
334 return username
343
335
344
336
345 class SearchForm(NeboardForm):
337 class SearchForm(NeboardForm):
346 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
338 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
@@ -1,90 +1,105 b''
1 import hashlib
1 import hashlib
2 import os
2 import os
3 from random import random
3 from random import random
4 import time
4 import time
5 from django.db import models
5 from django.db import models
6 from boards import thumbs
6 from boards import thumbs
7 from boards.models.base import Viewable
7 from boards.models.base import Viewable
8
8
9 __author__ = 'neko259'
9 __author__ = 'neko259'
10
10
11
11
12 IMAGE_THUMB_SIZE = (200, 150)
12 IMAGE_THUMB_SIZE = (200, 150)
13 IMAGES_DIRECTORY = 'images/'
13 IMAGES_DIRECTORY = 'images/'
14 FILE_EXTENSION_DELIMITER = '.'
14 FILE_EXTENSION_DELIMITER = '.'
15 HASH_LENGTH = 36
15 HASH_LENGTH = 36
16
16
17 CSS_CLASS_IMAGE = 'image'
17 CSS_CLASS_IMAGE = 'image'
18 CSS_CLASS_THUMB = 'thumb'
18 CSS_CLASS_THUMB = 'thumb'
19
19
20
20
21 class PostImageManager(models.Manager):
22 def create_with_hash(self, image):
23 image_hash = self.get_hash(image)
24 existing = self.filter(hash=image_hash)
25 if len(existing) > 0:
26 post_image = existing[0]
27 else:
28 post_image = PostImage.objects.create(image=image)
29
30 return post_image
31
32 def get_hash(self, image):
33 """
34 Gets hash of an image.
35 """
36 md5 = hashlib.md5()
37 for chunk in image.chunks():
38 md5.update(chunk)
39 return md5.hexdigest()
40
41
21 class PostImage(models.Model, Viewable):
42 class PostImage(models.Model, Viewable):
43 objects = PostImageManager()
44
22 class Meta:
45 class Meta:
23 app_label = 'boards'
46 app_label = 'boards'
24 ordering = ('id',)
47 ordering = ('id',)
25
48
26 def _update_image_filename(self, filename):
49 def _update_image_filename(self, filename):
27 """
50 """
28 Gets unique image filename
51 Gets unique image filename
29 """
52 """
30
53
31 path = IMAGES_DIRECTORY
54 path = IMAGES_DIRECTORY
32 new_name = str(int(time.mktime(time.gmtime())))
55
33 new_name += str(int(random() * 1000))
56 # TODO Use something other than random number in file name
34 new_name += FILE_EXTENSION_DELIMITER
57 new_name = '{}{}.{}'.format(
35 new_name += filename.split(FILE_EXTENSION_DELIMITER)[-1:][0]
58 str(int(time.mktime(time.gmtime()))),
59 str(int(random() * 1000)),
60 filename.split(FILE_EXTENSION_DELIMITER)[-1:][0])
36
61
37 return os.path.join(path, new_name)
62 return os.path.join(path, new_name)
38
63
39 width = models.IntegerField(default=0)
64 width = models.IntegerField(default=0)
40 height = models.IntegerField(default=0)
65 height = models.IntegerField(default=0)
41
66
42 pre_width = models.IntegerField(default=0)
67 pre_width = models.IntegerField(default=0)
43 pre_height = models.IntegerField(default=0)
68 pre_height = models.IntegerField(default=0)
44
69
45 image = thumbs.ImageWithThumbsField(upload_to=_update_image_filename,
70 image = thumbs.ImageWithThumbsField(upload_to=_update_image_filename,
46 blank=True, sizes=(IMAGE_THUMB_SIZE,),
71 blank=True, sizes=(IMAGE_THUMB_SIZE,),
47 width_field='width',
72 width_field='width',
48 height_field='height',
73 height_field='height',
49 preview_width_field='pre_width',
74 preview_width_field='pre_width',
50 preview_height_field='pre_height')
75 preview_height_field='pre_height')
51 hash = models.CharField(max_length=HASH_LENGTH)
76 hash = models.CharField(max_length=HASH_LENGTH)
52
77
53 def save(self, *args, **kwargs):
78 def save(self, *args, **kwargs):
54 """
79 """
55 Saves the model and computes the image hash for deduplication purposes.
80 Saves the model and computes the image hash for deduplication purposes.
56 """
81 """
57
82
58 if not self.pk and self.image:
83 if not self.pk and self.image:
59 self.hash = PostImage.get_hash(self.image)
84 self.hash = PostImage.objects.get_hash(self.image)
60 super(PostImage, self).save(*args, **kwargs)
85 super(PostImage, self).save(*args, **kwargs)
61
86
62 def __str__(self):
87 def __str__(self):
63 return self.image.url
88 return self.image.url
64
89
65 def get_view(self):
90 def get_view(self):
66 return '<div class="{}">' \
91 return '<div class="{}">' \
67 '<a class="{}" href="{}">' \
92 '<a class="{}" href="{}">' \
68 '<img' \
93 '<img' \
69 ' src="{}"' \
94 ' src="{}"' \
70 ' alt="{}"' \
95 ' alt="{}"' \
71 ' width="{}"' \
96 ' width="{}"' \
72 ' height="{}"' \
97 ' height="{}"' \
73 ' data-width="{}"' \
98 ' data-width="{}"' \
74 ' data-height="{}" />' \
99 ' data-height="{}" />' \
75 '</a>' \
100 '</a>' \
76 '</div>'\
101 '</div>'\
77 .format(CSS_CLASS_IMAGE, CSS_CLASS_THUMB, self.image.url,
102 .format(CSS_CLASS_IMAGE, CSS_CLASS_THUMB, self.image.url,
78 self.image.url_200x150,
103 self.image.url_200x150,
79 str(self.hash), str(self.pre_width),
104 str(self.hash), str(self.pre_width),
80 str(self.pre_height), str(self.width), str(self.height))
105 str(self.pre_height), str(self.width), str(self.height))
81
82 @staticmethod
83 def get_hash(image):
84 """
85 Gets hash of an image.
86 """
87 md5 = hashlib.md5()
88 for chunk in image.chunks():
89 md5.update(chunk)
90 return md5.hexdigest()
@@ -1,631 +1,621 b''
1 from datetime import datetime, timedelta, date
1 from datetime import datetime, timedelta, date
2 from datetime import time as dtime
2 from datetime import time as dtime
3 import logging
3 import logging
4 import re
4 import re
5 import xml.etree.ElementTree as et
5 import xml.etree.ElementTree as et
6 from urllib.parse import unquote
6 from urllib.parse import unquote
7
7
8 from adjacent import Client
8 from adjacent import Client
9 from django.core.urlresolvers import reverse
9 from django.core.urlresolvers import reverse
10 from django.db import models, transaction
10 from django.db import models, transaction
11 from django.db.models import TextField
11 from django.db.models import TextField
12 from django.template.loader import render_to_string
12 from django.template.loader import render_to_string
13 from django.utils import timezone
13 from django.utils import timezone
14
14
15 from boards.models import KeyPair, GlobalId, Signature
15 from boards.models import KeyPair, GlobalId, Signature
16 from boards import settings, utils
16 from boards import settings, utils
17 from boards.mdx_neboard import bbcode_extended
17 from boards.mdx_neboard import bbcode_extended
18 from boards.models import PostImage
18 from boards.models import PostImage
19 from boards.models.base import Viewable
19 from boards.models.base import Viewable
20 from boards.utils import datetime_to_epoch, cached_result
20 from boards.utils import datetime_to_epoch, cached_result
21 from boards.models.user import Notification
21 from boards.models.user import Notification
22 import boards.models.thread
22 import boards.models.thread
23
23
24
24
25 ENCODING_UNICODE = 'unicode'
25 ENCODING_UNICODE = 'unicode'
26
26
27 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
27 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
28 WS_NOTIFICATION_TYPE = 'notification_type'
28 WS_NOTIFICATION_TYPE = 'notification_type'
29
29
30 WS_CHANNEL_THREAD = "thread:"
30 WS_CHANNEL_THREAD = "thread:"
31
31
32 APP_LABEL_BOARDS = 'boards'
32 APP_LABEL_BOARDS = 'boards'
33
33
34 POSTS_PER_DAY_RANGE = 7
34 POSTS_PER_DAY_RANGE = 7
35
35
36 BAN_REASON_AUTO = 'Auto'
36 BAN_REASON_AUTO = 'Auto'
37
37
38 IMAGE_THUMB_SIZE = (200, 150)
38 IMAGE_THUMB_SIZE = (200, 150)
39
39
40 TITLE_MAX_LENGTH = 200
40 TITLE_MAX_LENGTH = 200
41
41
42 # TODO This should be removed
42 # TODO This should be removed
43 NO_IP = '0.0.0.0'
43 NO_IP = '0.0.0.0'
44
44
45 # TODO Real user agent should be saved instead of this
45 # TODO Real user agent should be saved instead of this
46 UNKNOWN_UA = ''
46 UNKNOWN_UA = ''
47
47
48 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
48 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
49 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
49 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
50 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
50 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
51 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
51 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
52
52
53 TAG_MODEL = 'model'
53 TAG_MODEL = 'model'
54 TAG_REQUEST = 'request'
54 TAG_REQUEST = 'request'
55 TAG_RESPONSE = 'response'
55 TAG_RESPONSE = 'response'
56 TAG_ID = 'id'
56 TAG_ID = 'id'
57 TAG_STATUS = 'status'
57 TAG_STATUS = 'status'
58 TAG_MODELS = 'models'
58 TAG_MODELS = 'models'
59 TAG_TITLE = 'title'
59 TAG_TITLE = 'title'
60 TAG_TEXT = 'text'
60 TAG_TEXT = 'text'
61 TAG_THREAD = 'thread'
61 TAG_THREAD = 'thread'
62 TAG_PUB_TIME = 'pub-time'
62 TAG_PUB_TIME = 'pub-time'
63 TAG_SIGNATURES = 'signatures'
63 TAG_SIGNATURES = 'signatures'
64 TAG_SIGNATURE = 'signature'
64 TAG_SIGNATURE = 'signature'
65 TAG_CONTENT = 'content'
65 TAG_CONTENT = 'content'
66 TAG_ATTACHMENTS = 'attachments'
66 TAG_ATTACHMENTS = 'attachments'
67 TAG_ATTACHMENT = 'attachment'
67 TAG_ATTACHMENT = 'attachment'
68
68
69 TYPE_GET = 'get'
69 TYPE_GET = 'get'
70
70
71 ATTR_VERSION = 'version'
71 ATTR_VERSION = 'version'
72 ATTR_TYPE = 'type'
72 ATTR_TYPE = 'type'
73 ATTR_NAME = 'name'
73 ATTR_NAME = 'name'
74 ATTR_VALUE = 'value'
74 ATTR_VALUE = 'value'
75 ATTR_MIMETYPE = 'mimetype'
75 ATTR_MIMETYPE = 'mimetype'
76
76
77 STATUS_SUCCESS = 'success'
77 STATUS_SUCCESS = 'success'
78
78
79 PARAMETER_TRUNCATED = 'truncated'
79 PARAMETER_TRUNCATED = 'truncated'
80 PARAMETER_TAG = 'tag'
80 PARAMETER_TAG = 'tag'
81 PARAMETER_OFFSET = 'offset'
81 PARAMETER_OFFSET = 'offset'
82 PARAMETER_DIFF_TYPE = 'type'
82 PARAMETER_DIFF_TYPE = 'type'
83 PARAMETER_BUMPABLE = 'bumpable'
83 PARAMETER_BUMPABLE = 'bumpable'
84 PARAMETER_THREAD = 'thread'
84 PARAMETER_THREAD = 'thread'
85 PARAMETER_IS_OPENING = 'is_opening'
85 PARAMETER_IS_OPENING = 'is_opening'
86 PARAMETER_MODERATOR = 'moderator'
86 PARAMETER_MODERATOR = 'moderator'
87 PARAMETER_POST = 'post'
87 PARAMETER_POST = 'post'
88 PARAMETER_OP_ID = 'opening_post_id'
88 PARAMETER_OP_ID = 'opening_post_id'
89 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
89 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
90
90
91 DIFF_TYPE_HTML = 'html'
91 DIFF_TYPE_HTML = 'html'
92 DIFF_TYPE_JSON = 'json'
92 DIFF_TYPE_JSON = 'json'
93
93
94 PREPARSE_PATTERNS = {
94 PREPARSE_PATTERNS = {
95 r'>>(\d+)': r'[post]\1[/post]', # Reflink ">>123"
95 r'>>(\d+)': r'[post]\1[/post]', # Reflink ">>123"
96 r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text"
96 r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text"
97 r'^//(.+)': r'[comment]\1[/comment]', # Comment "//text"
97 r'^//(.+)': r'[comment]\1[/comment]', # Comment "//text"
98 }
98 }
99
99
100
100
101 class PostManager(models.Manager):
101 class PostManager(models.Manager):
102 @transaction.atomic
102 @transaction.atomic
103 def create_post(self, title: str, text: str, image=None, thread=None,
103 def create_post(self, title: str, text: str, image=None, thread=None,
104 ip=NO_IP, tags: list=None):
104 ip=NO_IP, tags: list=None):
105 """
105 """
106 Creates new post
106 Creates new post
107 """
107 """
108
108
109 if not tags:
109 if not tags:
110 tags = []
110 tags = []
111
111
112 posting_time = timezone.now()
112 posting_time = timezone.now()
113 if not thread:
113 if not thread:
114 thread = boards.models.thread.Thread.objects.create(
114 thread = boards.models.thread.Thread.objects.create(
115 bump_time=posting_time, last_edit_time=posting_time)
115 bump_time=posting_time, last_edit_time=posting_time)
116 new_thread = True
116 new_thread = True
117 else:
117 else:
118 new_thread = False
118 new_thread = False
119
119
120 pre_text = self._preparse_text(text)
120 pre_text = self._preparse_text(text)
121
121
122 post = self.create(title=title,
122 post = self.create(title=title,
123 text=pre_text,
123 text=pre_text,
124 pub_time=posting_time,
124 pub_time=posting_time,
125 poster_ip=ip,
125 poster_ip=ip,
126 thread=thread,
126 thread=thread,
127 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
127 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
128 # last!
128 # last!
129 last_edit_time=posting_time)
129 last_edit_time=posting_time)
130 post.threads.add(thread)
130 post.threads.add(thread)
131
131
132 post.set_global_id()
132 post.set_global_id()
133
133
134 logger = logging.getLogger('boards.post.create')
134 logger = logging.getLogger('boards.post.create')
135
135
136 logger.info('Created post {} by {}'.format(
136 logger.info('Created post {} by {}'.format(
137 post, post.poster_ip))
137 post, post.poster_ip))
138
138
139 if image:
139 if image:
140 # Try to find existing image. If it exists, assign it to the post
140 post.images.add(PostImage.objects.create_with_hash(image))
141 # instead of createing the new one
142 image_hash = PostImage.get_hash(image)
143 existing = PostImage.objects.filter(hash=image_hash)
144 if len(existing) > 0:
145 post_image = existing[0]
146 else:
147 post_image = PostImage.objects.create(image=image)
148 logger.info('Created new image #{} for post #{}'.format(
149 post_image.id, post.id))
150 post.images.add(post_image)
151
141
152 list(map(thread.add_tag, tags))
142 list(map(thread.add_tag, tags))
153
143
154 if new_thread:
144 if new_thread:
155 boards.models.thread.Thread.objects.process_oldest_threads()
145 boards.models.thread.Thread.objects.process_oldest_threads()
156 else:
146 else:
147 thread.last_edit_time = posting_time
157 thread.bump()
148 thread.bump()
158 thread.last_edit_time = posting_time
159 thread.save()
149 thread.save()
160
150
161 post.connect_replies()
151 post.connect_replies()
162 post.connect_notifications()
152 post.connect_notifications()
163
153
164 return post
154 return post
165
155
166 def delete_posts_by_ip(self, ip):
156 def delete_posts_by_ip(self, ip):
167 """
157 """
168 Deletes all posts of the author with same IP
158 Deletes all posts of the author with same IP
169 """
159 """
170
160
171 posts = self.filter(poster_ip=ip)
161 posts = self.filter(poster_ip=ip)
172 for post in posts:
162 for post in posts:
173 post.delete()
163 post.delete()
174
164
175 @cached_result
165 @cached_result
176 def get_posts_per_day(self):
166 def get_posts_per_day(self):
177 """
167 """
178 Gets average count of posts per day for the last 7 days
168 Gets average count of posts per day for the last 7 days
179 """
169 """
180
170
181 day_end = date.today()
171 day_end = date.today()
182 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
172 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
183
173
184 day_time_start = timezone.make_aware(datetime.combine(
174 day_time_start = timezone.make_aware(datetime.combine(
185 day_start, dtime()), timezone.get_current_timezone())
175 day_start, dtime()), timezone.get_current_timezone())
186 day_time_end = timezone.make_aware(datetime.combine(
176 day_time_end = timezone.make_aware(datetime.combine(
187 day_end, dtime()), timezone.get_current_timezone())
177 day_end, dtime()), timezone.get_current_timezone())
188
178
189 posts_per_period = float(self.filter(
179 posts_per_period = float(self.filter(
190 pub_time__lte=day_time_end,
180 pub_time__lte=day_time_end,
191 pub_time__gte=day_time_start).count())
181 pub_time__gte=day_time_start).count())
192
182
193 ppd = posts_per_period / POSTS_PER_DAY_RANGE
183 ppd = posts_per_period / POSTS_PER_DAY_RANGE
194
184
195 return ppd
185 return ppd
196
186
197 # TODO Make a separate sync facade?
187 # TODO Make a separate sync facade?
198 def generate_response_get(self, model_list: list):
188 def generate_response_get(self, model_list: list):
199 response = et.Element(TAG_RESPONSE)
189 response = et.Element(TAG_RESPONSE)
200
190
201 status = et.SubElement(response, TAG_STATUS)
191 status = et.SubElement(response, TAG_STATUS)
202 status.text = STATUS_SUCCESS
192 status.text = STATUS_SUCCESS
203
193
204 models = et.SubElement(response, TAG_MODELS)
194 models = et.SubElement(response, TAG_MODELS)
205
195
206 for post in model_list:
196 for post in model_list:
207 model = et.SubElement(models, TAG_MODEL)
197 model = et.SubElement(models, TAG_MODEL)
208 model.set(ATTR_NAME, 'post')
198 model.set(ATTR_NAME, 'post')
209
199
210 content_tag = et.SubElement(model, TAG_CONTENT)
200 content_tag = et.SubElement(model, TAG_CONTENT)
211
201
212 tag_id = et.SubElement(content_tag, TAG_ID)
202 tag_id = et.SubElement(content_tag, TAG_ID)
213 post.global_id.to_xml_element(tag_id)
203 post.global_id.to_xml_element(tag_id)
214
204
215 title = et.SubElement(content_tag, TAG_TITLE)
205 title = et.SubElement(content_tag, TAG_TITLE)
216 title.text = post.title
206 title.text = post.title
217
207
218 text = et.SubElement(content_tag, TAG_TEXT)
208 text = et.SubElement(content_tag, TAG_TEXT)
219 # TODO Replace local links by global ones in the text
209 # TODO Replace local links by global ones in the text
220 text.text = post.get_raw_text()
210 text.text = post.get_raw_text()
221
211
222 if not post.is_opening():
212 if not post.is_opening():
223 thread = et.SubElement(content_tag, TAG_THREAD)
213 thread = et.SubElement(content_tag, TAG_THREAD)
224 thread_id = et.SubElement(thread, TAG_ID)
214 thread_id = et.SubElement(thread, TAG_ID)
225 post.get_thread().get_opening_post().global_id.to_xml_element(thread_id)
215 post.get_thread().get_opening_post().global_id.to_xml_element(thread_id)
226 else:
216 else:
227 # TODO Output tags here
217 # TODO Output tags here
228 pass
218 pass
229
219
230 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
220 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
231 pub_time.text = str(post.get_pub_time_epoch())
221 pub_time.text = str(post.get_pub_time_epoch())
232
222
233 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
223 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
234 post_signatures = post.signature.all()
224 post_signatures = post.signature.all()
235 if post_signatures:
225 if post_signatures:
236 signatures = post.signatures
226 signatures = post.signatures
237 else:
227 else:
238 # TODO Maybe the signature can be computed only once after
228 # TODO Maybe the signature can be computed only once after
239 # the post is added? Need to add some on_save signal queue
229 # the post is added? Need to add some on_save signal queue
240 # and add this there.
230 # and add this there.
241 key = KeyPair.objects.get(public_key=post.global_id.key)
231 key = KeyPair.objects.get(public_key=post.global_id.key)
242 signatures = [Signature(
232 signatures = [Signature(
243 key_type=key.key_type,
233 key_type=key.key_type,
244 key=key.public_key,
234 key=key.public_key,
245 signature=key.sign(et.tostring(model, ENCODING_UNICODE)),
235 signature=key.sign(et.tostring(model, ENCODING_UNICODE)),
246 )]
236 )]
247 for signature in signatures:
237 for signature in signatures:
248 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
238 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
249 signature_tag.set(ATTR_TYPE, signature.key_type)
239 signature_tag.set(ATTR_TYPE, signature.key_type)
250 signature_tag.set(ATTR_VALUE, signature.signature)
240 signature_tag.set(ATTR_VALUE, signature.signature)
251
241
252 return et.tostring(response, ENCODING_UNICODE)
242 return et.tostring(response, ENCODING_UNICODE)
253
243
254 def parse_response_get(self, response_xml):
244 def parse_response_get(self, response_xml):
255 tag_root = et.fromstring(response_xml)
245 tag_root = et.fromstring(response_xml)
256 tag_status = tag_root[0]
246 tag_status = tag_root[0]
257 if 'success' == tag_status.text:
247 if 'success' == tag_status.text:
258 tag_models = tag_root[1]
248 tag_models = tag_root[1]
259 for tag_model in tag_models:
249 for tag_model in tag_models:
260 tag_content = tag_model[0]
250 tag_content = tag_model[0]
261 tag_id = tag_content[1]
251 tag_id = tag_content[1]
262 try:
252 try:
263 GlobalId.from_xml_element(tag_id, existing=True)
253 GlobalId.from_xml_element(tag_id, existing=True)
264 # If this post already exists, just continue
254 # If this post already exists, just continue
265 # TODO Compare post content and update the post if necessary
255 # TODO Compare post content and update the post if necessary
266 pass
256 pass
267 except GlobalId.DoesNotExist:
257 except GlobalId.DoesNotExist:
268 global_id = GlobalId.from_xml_element(tag_id)
258 global_id = GlobalId.from_xml_element(tag_id)
269
259
270 title = tag_content.find(TAG_TITLE).text
260 title = tag_content.find(TAG_TITLE).text
271 text = tag_content.find(TAG_TEXT).text
261 text = tag_content.find(TAG_TEXT).text
272 # TODO Check that the replied posts are already present
262 # TODO Check that the replied posts are already present
273 # before adding new ones
263 # before adding new ones
274
264
275 # TODO Pub time, thread, tags
265 # TODO Pub time, thread, tags
276
266
277 post = Post.objects.create(title=title, text=text)
267 post = Post.objects.create(title=title, text=text)
278 else:
268 else:
279 # TODO Throw an exception?
269 # TODO Throw an exception?
280 pass
270 pass
281
271
282 # TODO Make a separate parser module and move preparser there
272 # TODO Make a separate parser module and move preparser there
283 def _preparse_text(self, text: str) -> str:
273 def _preparse_text(self, text: str) -> str:
284 """
274 """
285 Preparses text to change patterns like '>>' to a proper bbcode
275 Preparses text to change patterns like '>>' to a proper bbcode
286 tags.
276 tags.
287 """
277 """
288
278
289 for key, value in PREPARSE_PATTERNS.items():
279 for key, value in PREPARSE_PATTERNS.items():
290 text = re.sub(key, value, text, flags=re.MULTILINE)
280 text = re.sub(key, value, text, flags=re.MULTILINE)
291
281
292 for link in REGEX_URL.findall(text):
282 for link in REGEX_URL.findall(text):
293 text = text.replace(link, unquote(link))
283 text = text.replace(link, unquote(link))
294
284
295 return text
285 return text
296
286
297
287
298 class Post(models.Model, Viewable):
288 class Post(models.Model, Viewable):
299 """A post is a message."""
289 """A post is a message."""
300
290
301 objects = PostManager()
291 objects = PostManager()
302
292
303 class Meta:
293 class Meta:
304 app_label = APP_LABEL_BOARDS
294 app_label = APP_LABEL_BOARDS
305 ordering = ('id',)
295 ordering = ('id',)
306
296
307 title = models.CharField(max_length=TITLE_MAX_LENGTH)
297 title = models.CharField(max_length=TITLE_MAX_LENGTH)
308 pub_time = models.DateTimeField()
298 pub_time = models.DateTimeField()
309 text = TextField(blank=True, null=True)
299 text = TextField(blank=True, null=True)
310 _text_rendered = TextField(blank=True, null=True, editable=False)
300 _text_rendered = TextField(blank=True, null=True, editable=False)
311
301
312 images = models.ManyToManyField(PostImage, null=True, blank=True,
302 images = models.ManyToManyField(PostImage, null=True, blank=True,
313 related_name='ip+', db_index=True)
303 related_name='ip+', db_index=True)
314
304
315 poster_ip = models.GenericIPAddressField()
305 poster_ip = models.GenericIPAddressField()
316 poster_user_agent = models.TextField()
306 poster_user_agent = models.TextField()
317
307
318 last_edit_time = models.DateTimeField()
308 last_edit_time = models.DateTimeField()
319
309
320 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
310 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
321 null=True,
311 null=True,
322 blank=True, related_name='rfp+',
312 blank=True, related_name='rfp+',
323 db_index=True)
313 db_index=True)
324 refmap = models.TextField(null=True, blank=True)
314 refmap = models.TextField(null=True, blank=True)
325 threads = models.ManyToManyField('Thread', db_index=True)
315 threads = models.ManyToManyField('Thread', db_index=True)
326 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
316 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
327
317
328 # Global ID with author key. If the message was downloaded from another
318 # Global ID with author key. If the message was downloaded from another
329 # server, this indicates the server.
319 # server, this indicates the server.
330 global_id = models.OneToOneField('GlobalId', null=True, blank=True)
320 global_id = models.OneToOneField('GlobalId', null=True, blank=True)
331
321
332 # One post can be signed by many nodes that give their trust to it
322 # One post can be signed by many nodes that give their trust to it
333 signature = models.ManyToManyField('Signature', null=True, blank=True)
323 signature = models.ManyToManyField('Signature', null=True, blank=True)
334
324
335 def __str__(self):
325 def __str__(self):
336 return 'P#{}/{}'.format(self.id, self.title)
326 return 'P#{}/{}'.format(self.id, self.title)
337
327
338 def get_title(self) -> str:
328 def get_title(self) -> str:
339 """
329 """
340 Gets original post title or part of its text.
330 Gets original post title or part of its text.
341 """
331 """
342
332
343 title = self.title
333 title = self.title
344 if not title:
334 if not title:
345 title = self.get_text()
335 title = self.get_text()
346
336
347 return title
337 return title
348
338
349 def build_refmap(self) -> None:
339 def build_refmap(self) -> None:
350 """
340 """
351 Builds a replies map string from replies list. This is a cache to stop
341 Builds a replies map string from replies list. This is a cache to stop
352 the server from recalculating the map on every post show.
342 the server from recalculating the map on every post show.
353 """
343 """
354 post_urls = ['<a href="{}">&gt;&gt;{}</a>'.format(
344 post_urls = ['<a href="{}">&gt;&gt;{}</a>'.format(
355 refpost.get_url(), refpost.id) for refpost in self.referenced_posts.all()]
345 refpost.get_url(), refpost.id) for refpost in self.referenced_posts.all()]
356
346
357 self.refmap = ', '.join(post_urls)
347 self.refmap = ', '.join(post_urls)
358
348
359 def get_sorted_referenced_posts(self):
349 def get_sorted_referenced_posts(self):
360 return self.refmap
350 return self.refmap
361
351
362 def is_referenced(self) -> bool:
352 def is_referenced(self) -> bool:
363 return self.refmap and len(self.refmap) > 0
353 return self.refmap and len(self.refmap) > 0
364
354
365 def is_opening(self) -> bool:
355 def is_opening(self) -> bool:
366 """
356 """
367 Checks if this is an opening post or just a reply.
357 Checks if this is an opening post or just a reply.
368 """
358 """
369
359
370 return self.get_thread().get_opening_post_id() == self.id
360 return self.get_thread().get_opening_post_id() == self.id
371
361
372 @transaction.atomic
362 @transaction.atomic
373 def add_tag(self, tag):
363 def add_tag(self, tag):
374 edit_time = timezone.now()
364 edit_time = timezone.now()
375
365
376 thread = self.get_thread()
366 thread = self.get_thread()
377 thread.add_tag(tag)
367 thread.add_tag(tag)
378 self.last_edit_time = edit_time
368 self.last_edit_time = edit_time
379 self.save(update_fields=['last_edit_time'])
369 self.save(update_fields=['last_edit_time'])
380
370
381 thread.last_edit_time = edit_time
371 thread.last_edit_time = edit_time
382 thread.save(update_fields=['last_edit_time'])
372 thread.save(update_fields=['last_edit_time'])
383
373
384 @cached_result
374 @cached_result
385 def get_url(self):
375 def get_url(self):
386 """
376 """
387 Gets full url to the post.
377 Gets full url to the post.
388 """
378 """
389
379
390 thread = self.get_thread()
380 thread = self.get_thread()
391
381
392 opening_id = thread.get_opening_post_id()
382 opening_id = thread.get_opening_post_id()
393
383
394 if self.id != opening_id:
384 if self.id != opening_id:
395 link = reverse('thread', kwargs={
385 link = reverse('thread', kwargs={
396 'post_id': opening_id}) + '#' + str(self.id)
386 'post_id': opening_id}) + '#' + str(self.id)
397 else:
387 else:
398 link = reverse('thread', kwargs={'post_id': self.id})
388 link = reverse('thread', kwargs={'post_id': self.id})
399
389
400 return link
390 return link
401
391
402 def get_thread(self):
392 def get_thread(self):
403 return self.thread
393 return self.thread
404
394
405 def get_threads(self):
395 def get_threads(self):
406 """
396 """
407 Gets post's thread.
397 Gets post's thread.
408 """
398 """
409
399
410 return self.threads
400 return self.threads
411
401
412 def get_referenced_posts(self):
402 def get_referenced_posts(self):
413 return self.referenced_posts.only('id', 'threads')
403 return self.referenced_posts.only('id', 'threads')
414
404
415 def get_view(self, moderator=False, need_open_link=False,
405 def get_view(self, moderator=False, need_open_link=False,
416 truncated=False, *args, **kwargs):
406 truncated=False, *args, **kwargs):
417 """
407 """
418 Renders post's HTML view. Some of the post params can be passed over
408 Renders post's HTML view. Some of the post params can be passed over
419 kwargs for the means of caching (if we view the thread, some params
409 kwargs for the means of caching (if we view the thread, some params
420 are same for every post and don't need to be computed over and over.
410 are same for every post and don't need to be computed over and over.
421 """
411 """
422
412
423 thread = self.get_thread()
413 thread = self.get_thread()
424 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
414 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
425 can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump())
415 can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump())
426
416
427 if is_opening:
417 if is_opening:
428 opening_post_id = self.id
418 opening_post_id = self.id
429 else:
419 else:
430 opening_post_id = thread.get_opening_post_id()
420 opening_post_id = thread.get_opening_post_id()
431
421
432 return render_to_string('boards/post.html', {
422 return render_to_string('boards/post.html', {
433 PARAMETER_POST: self,
423 PARAMETER_POST: self,
434 PARAMETER_MODERATOR: moderator,
424 PARAMETER_MODERATOR: moderator,
435 PARAMETER_IS_OPENING: is_opening,
425 PARAMETER_IS_OPENING: is_opening,
436 PARAMETER_THREAD: thread,
426 PARAMETER_THREAD: thread,
437 PARAMETER_BUMPABLE: can_bump,
427 PARAMETER_BUMPABLE: can_bump,
438 PARAMETER_NEED_OPEN_LINK: need_open_link,
428 PARAMETER_NEED_OPEN_LINK: need_open_link,
439 PARAMETER_TRUNCATED: truncated,
429 PARAMETER_TRUNCATED: truncated,
440 PARAMETER_OP_ID: opening_post_id,
430 PARAMETER_OP_ID: opening_post_id,
441 })
431 })
442
432
443 def get_search_view(self, *args, **kwargs):
433 def get_search_view(self, *args, **kwargs):
444 return self.get_view(args, kwargs)
434 return self.get_view(args, kwargs)
445
435
446 def get_first_image(self) -> PostImage:
436 def get_first_image(self) -> PostImage:
447 return self.images.earliest('id')
437 return self.images.earliest('id')
448
438
449 def delete(self, using=None):
439 def delete(self, using=None):
450 """
440 """
451 Deletes all post images and the post itself.
441 Deletes all post images and the post itself.
452 """
442 """
453
443
454 for image in self.images.all():
444 for image in self.images.all():
455 image_refs_count = Post.objects.filter(images__in=[image]).count()
445 image_refs_count = Post.objects.filter(images__in=[image]).count()
456 if image_refs_count == 1:
446 if image_refs_count == 1:
457 image.delete()
447 image.delete()
458
448
459 self.signature.all().delete()
449 self.signature.all().delete()
460 if self.global_id:
450 if self.global_id:
461 self.global_id.delete()
451 self.global_id.delete()
462
452
463 thread = self.get_thread()
453 thread = self.get_thread()
464 thread.last_edit_time = timezone.now()
454 thread.last_edit_time = timezone.now()
465 thread.save()
455 thread.save()
466
456
467 super(Post, self).delete(using)
457 super(Post, self).delete(using)
468
458
469 logging.getLogger('boards.post.delete').info(
459 logging.getLogger('boards.post.delete').info(
470 'Deleted post {}'.format(self))
460 'Deleted post {}'.format(self))
471
461
472 def set_global_id(self, key_pair=None):
462 def set_global_id(self, key_pair=None):
473 """
463 """
474 Sets global id based on the given key pair. If no key pair is given,
464 Sets global id based on the given key pair. If no key pair is given,
475 default one is used.
465 default one is used.
476 """
466 """
477
467
478 if key_pair:
468 if key_pair:
479 key = key_pair
469 key = key_pair
480 else:
470 else:
481 try:
471 try:
482 key = KeyPair.objects.get(primary=True)
472 key = KeyPair.objects.get(primary=True)
483 except KeyPair.DoesNotExist:
473 except KeyPair.DoesNotExist:
484 # Do not update the global id because there is no key defined
474 # Do not update the global id because there is no key defined
485 return
475 return
486 global_id = GlobalId(key_type=key.key_type,
476 global_id = GlobalId(key_type=key.key_type,
487 key=key.public_key,
477 key=key.public_key,
488 local_id = self.id)
478 local_id = self.id)
489 global_id.save()
479 global_id.save()
490
480
491 self.global_id = global_id
481 self.global_id = global_id
492
482
493 self.save(update_fields=['global_id'])
483 self.save(update_fields=['global_id'])
494
484
495 def get_pub_time_epoch(self):
485 def get_pub_time_epoch(self):
496 return utils.datetime_to_epoch(self.pub_time)
486 return utils.datetime_to_epoch(self.pub_time)
497
487
498 def get_replied_ids(self):
488 def get_replied_ids(self):
499 """
489 """
500 Gets ID list of the posts that this post replies.
490 Gets ID list of the posts that this post replies.
501 """
491 """
502
492
503 raw_text = self.get_raw_text()
493 raw_text = self.get_raw_text()
504
494
505 local_replied = REGEX_REPLY.findall(raw_text)
495 local_replied = REGEX_REPLY.findall(raw_text)
506 global_replied = []
496 global_replied = []
507 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
497 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
508 key_type = match[0]
498 key_type = match[0]
509 key = match[1]
499 key = match[1]
510 local_id = match[2]
500 local_id = match[2]
511
501
512 try:
502 try:
513 global_id = GlobalId.objects.get(key_type=key_type,
503 global_id = GlobalId.objects.get(key_type=key_type,
514 key=key, local_id=local_id)
504 key=key, local_id=local_id)
515 for post in Post.objects.filter(global_id=global_id).only('id'):
505 for post in Post.objects.filter(global_id=global_id).only('id'):
516 global_replied.append(post.id)
506 global_replied.append(post.id)
517 except GlobalId.DoesNotExist:
507 except GlobalId.DoesNotExist:
518 pass
508 pass
519 return local_replied + global_replied
509 return local_replied + global_replied
520
510
521
511
522 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
512 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
523 include_last_update=False):
513 include_last_update=False):
524 """
514 """
525 Gets post HTML or JSON data that can be rendered on a page or used by
515 Gets post HTML or JSON data that can be rendered on a page or used by
526 API.
516 API.
527 """
517 """
528
518
529 if format_type == DIFF_TYPE_HTML:
519 if format_type == DIFF_TYPE_HTML:
530 params = dict()
520 params = dict()
531 params['post'] = self
521 params['post'] = self
532 if PARAMETER_TRUNCATED in request.GET:
522 if PARAMETER_TRUNCATED in request.GET:
533 params[PARAMETER_TRUNCATED] = True
523 params[PARAMETER_TRUNCATED] = True
534
524
535 return render_to_string('boards/api_post.html', params)
525 return render_to_string('boards/api_post.html', params)
536 elif format_type == DIFF_TYPE_JSON:
526 elif format_type == DIFF_TYPE_JSON:
537 post_json = {
527 post_json = {
538 'id': self.id,
528 'id': self.id,
539 'title': self.title,
529 'title': self.title,
540 'text': self._text_rendered,
530 'text': self._text_rendered,
541 }
531 }
542 if self.images.exists():
532 if self.images.exists():
543 post_image = self.get_first_image()
533 post_image = self.get_first_image()
544 post_json['image'] = post_image.image.url
534 post_json['image'] = post_image.image.url
545 post_json['image_preview'] = post_image.image.url_200x150
535 post_json['image_preview'] = post_image.image.url_200x150
546 if include_last_update:
536 if include_last_update:
547 post_json['bump_time'] = datetime_to_epoch(
537 post_json['bump_time'] = datetime_to_epoch(
548 self.get_thread().bump_time)
538 self.get_thread().bump_time)
549 return post_json
539 return post_json
550
540
551 def send_to_websocket(self, request, recursive=True):
541 def send_to_websocket(self, request, recursive=True):
552 """
542 """
553 Sends post HTML data to the thread web socket.
543 Sends post HTML data to the thread web socket.
554 """
544 """
555
545
556 if not settings.WEBSOCKETS_ENABLED:
546 if not settings.WEBSOCKETS_ENABLED:
557 return
547 return
558
548
559 client = Client()
549 client = Client()
560
550
561 thread = self.get_thread()
551 thread = self.get_thread()
562 thread_id = thread.id
552 thread_id = thread.id
563 channel_name = WS_CHANNEL_THREAD + str(thread.get_opening_post_id())
553 channel_name = WS_CHANNEL_THREAD + str(thread.get_opening_post_id())
564 client.publish(channel_name, {
554 client.publish(channel_name, {
565 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
555 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
566 })
556 })
567 client.send()
557 client.send()
568
558
569 logger = logging.getLogger('boards.post.websocket')
559 logger = logging.getLogger('boards.post.websocket')
570
560
571 logger.info('Sent notification from post #{} to channel {}'.format(
561 logger.info('Sent notification from post #{} to channel {}'.format(
572 self.id, channel_name))
562 self.id, channel_name))
573
563
574 if recursive:
564 if recursive:
575 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
565 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
576 post_id = reply_number.group(1)
566 post_id = reply_number.group(1)
577 ref_post = Post.objects.filter(id=post_id)[0]
567 ref_post = Post.objects.filter(id=post_id)[0]
578
568
579 # If post is in this thread, its thread was already notified.
569 # If post is in this thread, its thread was already notified.
580 # Otherwise, notify its thread separately.
570 # Otherwise, notify its thread separately.
581 if ref_post.get_thread().id != thread_id:
571 if ref_post.get_thread().id != thread_id:
582 ref_post.send_to_websocket(request, recursive=False)
572 ref_post.send_to_websocket(request, recursive=False)
583
573
584 def save(self, force_insert=False, force_update=False, using=None,
574 def save(self, force_insert=False, force_update=False, using=None,
585 update_fields=None):
575 update_fields=None):
586 self._text_rendered = bbcode_extended(self.get_raw_text())
576 self._text_rendered = bbcode_extended(self.get_raw_text())
587
577
588 super().save(force_insert, force_update, using, update_fields)
578 super().save(force_insert, force_update, using, update_fields)
589
579
590 def get_text(self) -> str:
580 def get_text(self) -> str:
591 return self._text_rendered
581 return self._text_rendered
592
582
593 def get_raw_text(self) -> str:
583 def get_raw_text(self) -> str:
594 return self.text
584 return self.text
595
585
596 def get_absolute_id(self) -> str:
586 def get_absolute_id(self) -> str:
597 """
587 """
598 If the post has many threads, shows its main thread OP id in the post
588 If the post has many threads, shows its main thread OP id in the post
599 ID.
589 ID.
600 """
590 """
601
591
602 if self.get_threads().count() > 1:
592 if self.get_threads().count() > 1:
603 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
593 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
604 else:
594 else:
605 return str(self.id)
595 return str(self.id)
606
596
607 def connect_notifications(self):
597 def connect_notifications(self):
608 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
598 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
609 user_name = reply_number.group(1).lower()
599 user_name = reply_number.group(1).lower()
610 Notification.objects.get_or_create(name=user_name, post=self)
600 Notification.objects.get_or_create(name=user_name, post=self)
611
601
612 def connect_replies(self):
602 def connect_replies(self):
613 """
603 """
614 Connects replies to a post to show them as a reflink map
604 Connects replies to a post to show them as a reflink map
615 """
605 """
616
606
617 for post_id in self.get_replied_ids():
607 for post_id in self.get_replied_ids():
618 ref_post = Post.objects.filter(id=post_id)
608 ref_post = Post.objects.filter(id=post_id)
619 if ref_post.count() > 0:
609 if ref_post.count() > 0:
620 referenced_post = ref_post[0]
610 referenced_post = ref_post[0]
621 referenced_post.referenced_posts.add(self)
611 referenced_post.referenced_posts.add(self)
622 referenced_post.last_edit_time = self.pub_time
612 referenced_post.last_edit_time = self.pub_time
623 referenced_post.build_refmap()
613 referenced_post.build_refmap()
624 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
614 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
625
615
626 referenced_threads = referenced_post.get_threads().all()
616 referenced_threads = referenced_post.get_threads().all()
627 for thread in referenced_threads:
617 for thread in referenced_threads:
628 thread.last_edit_time = self.pub_time
618 thread.last_edit_time = self.pub_time
629 thread.save(update_fields=['last_edit_time'])
619 thread.save(update_fields=['last_edit_time'])
630
620
631 self.threads.add(thread)
621 self.threads.add(thread)
@@ -1,76 +1,83 b''
1 from django.template.loader import render_to_string
1 from django.template.loader import render_to_string
2 from django.db import models
2 from django.db import models
3 from django.db.models import Count
3 from django.db.models import Count
4 from django.core.urlresolvers import reverse
4 from django.core.urlresolvers import reverse
5
5
6 from boards.models.base import Viewable
6 from boards.models.base import Viewable
7 from boards.utils import cached_result
7 from boards.utils import cached_result
8
8
9
9
10 __author__ = 'neko259'
10 __author__ = 'neko259'
11
11
12
12
13 class TagManager(models.Manager):
13 class TagManager(models.Manager):
14
14
15 def get_not_empty_tags(self):
15 def get_not_empty_tags(self):
16 """
16 """
17 Gets tags that have non-archived threads.
17 Gets tags that have non-archived threads.
18 """
18 """
19
19
20 return self.filter(thread__archived=False)\
20 return self.filter(thread__archived=False)\
21 .annotate(num_threads=Count('thread')).filter(num_threads__gt=0)\
21 .annotate(num_threads=Count('thread')).filter(num_threads__gt=0)\
22 .order_by('-required', 'name')
22 .order_by('-required', 'name')
23
23
24 def get_tag_url_list(self, tags: list) -> str:
25 """
26 Gets a comma-separated list of tag links.
27 """
28
29 return ', '.join([tag.get_view() for tag in tags])
30
24
31
25 class Tag(models.Model, Viewable):
32 class Tag(models.Model, Viewable):
26 """
33 """
27 A tag is a text node assigned to the thread. The tag serves as a board
34 A tag is a text node assigned to the thread. The tag serves as a board
28 section. There can be multiple tags for each thread
35 section. There can be multiple tags for each thread
29 """
36 """
30
37
31 objects = TagManager()
38 objects = TagManager()
32
39
33 class Meta:
40 class Meta:
34 app_label = 'boards'
41 app_label = 'boards'
35 ordering = ('name',)
42 ordering = ('name',)
36
43
37 name = models.CharField(max_length=100, db_index=True, unique=True)
44 name = models.CharField(max_length=100, db_index=True, unique=True)
38 required = models.BooleanField(default=False, db_index=True)
45 required = models.BooleanField(default=False, db_index=True)
39
46
40 def __str__(self):
47 def __str__(self):
41 return self.name
48 return self.name
42
49
43 def is_empty(self) -> bool:
50 def is_empty(self) -> bool:
44 """
51 """
45 Checks if the tag has some threads.
52 Checks if the tag has some threads.
46 """
53 """
47
54
48 return self.get_thread_count() == 0
55 return self.get_thread_count() == 0
49
56
50 def get_thread_count(self) -> int:
57 def get_thread_count(self) -> int:
51 return self.get_threads().count()
58 return self.get_threads().count()
52
59
53 def get_url(self):
60 def get_url(self):
54 return reverse('tag', kwargs={'tag_name': self.name})
61 return reverse('tag', kwargs={'tag_name': self.name})
55
62
56 def get_threads(self):
63 def get_threads(self):
57 return self.thread_set.order_by('-bump_time')
64 return self.thread_set.order_by('-bump_time')
58
65
59 def is_required(self):
66 def is_required(self):
60 return self.required
67 return self.required
61
68
62 def get_view(self):
69 def get_view(self):
63 link = '<a class="tag" href="{}">{}</a>'.format(
70 link = '<a class="tag" href="{}">{}</a>'.format(
64 self.get_url(), self.name)
71 self.get_url(), self.name)
65 if self.is_required():
72 if self.is_required():
66 link = '<b>{}</b>'.format(link)
73 link = '<b>{}</b>'.format(link)
67 return link
74 return link
68
75
69 def get_search_view(self, *args, **kwargs):
76 def get_search_view(self, *args, **kwargs):
70 return render_to_string('boards/tag.html', {
77 return render_to_string('boards/tag.html', {
71 'tag': self,
78 'tag': self,
72 })
79 })
73
80
74 @cached_result
81 @cached_result
75 def get_post_count(self):
82 def get_post_count(self):
76 return self.get_threads().aggregate(num_posts=Count('post'))['num_posts']
83 return self.get_threads().aggregate(num_posts=Count('post'))['num_posts']
@@ -1,185 +1,191 b''
1 import logging
1 import logging
2
2
3 from django.db.models import Count, Sum
3 from django.db.models import Count, Sum
4 from django.utils import timezone
4 from django.utils import timezone
5 from django.db import models
5 from django.db import models
6
6
7 from boards import settings
7 from boards import settings
8 import boards
8 from boards.utils import cached_result
9 from boards.utils import cached_result
9 from boards.models.post import Post
10 from boards.models.post import Post
10
11
11
12
12 __author__ = 'neko259'
13 __author__ = 'neko259'
13
14
14
15
15 logger = logging.getLogger(__name__)
16 logger = logging.getLogger(__name__)
16
17
17
18
18 class ThreadManager(models.Manager):
19 class ThreadManager(models.Manager):
19 def process_oldest_threads(self):
20 def process_oldest_threads(self):
20 """
21 """
21 Preserves maximum thread count. If there are too many threads,
22 Preserves maximum thread count. If there are too many threads,
22 archive or delete the old ones.
23 archive or delete the old ones.
23 """
24 """
24
25
25 threads = Thread.objects.filter(archived=False).order_by('-bump_time')
26 threads = Thread.objects.filter(archived=False).order_by('-bump_time')
26 thread_count = threads.count()
27 thread_count = threads.count()
27
28
28 if thread_count > settings.MAX_THREAD_COUNT:
29 if thread_count > settings.MAX_THREAD_COUNT:
29 num_threads_to_delete = thread_count - settings.MAX_THREAD_COUNT
30 num_threads_to_delete = thread_count - settings.MAX_THREAD_COUNT
30 old_threads = threads[thread_count - num_threads_to_delete:]
31 old_threads = threads[thread_count - num_threads_to_delete:]
31
32
32 for thread in old_threads:
33 for thread in old_threads:
33 if settings.ARCHIVE_THREADS:
34 if settings.ARCHIVE_THREADS:
34 self._archive_thread(thread)
35 self._archive_thread(thread)
35 else:
36 else:
36 thread.delete()
37 thread.delete()
37
38
38 logger.info('Processed %d old threads' % num_threads_to_delete)
39 logger.info('Processed %d old threads' % num_threads_to_delete)
39
40
40 def _archive_thread(self, thread):
41 def _archive_thread(self, thread):
41 thread.archived = True
42 thread.archived = True
42 thread.bumpable = False
43 thread.bumpable = False
43 thread.last_edit_time = timezone.now()
44 thread.last_edit_time = timezone.now()
45 thread.update_posts_time()
44 thread.save(update_fields=['archived', 'last_edit_time', 'bumpable'])
46 thread.save(update_fields=['archived', 'last_edit_time', 'bumpable'])
45
47
46
48
47 class Thread(models.Model):
49 class Thread(models.Model):
48 objects = ThreadManager()
50 objects = ThreadManager()
49
51
50 class Meta:
52 class Meta:
51 app_label = 'boards'
53 app_label = 'boards'
52
54
53 tags = models.ManyToManyField('Tag')
55 tags = models.ManyToManyField('Tag')
54 bump_time = models.DateTimeField(db_index=True)
56 bump_time = models.DateTimeField(db_index=True)
55 last_edit_time = models.DateTimeField()
57 last_edit_time = models.DateTimeField()
56 archived = models.BooleanField(default=False)
58 archived = models.BooleanField(default=False)
57 bumpable = models.BooleanField(default=True)
59 bumpable = models.BooleanField(default=True)
58
60
59 def get_tags(self):
61 def get_tags(self):
60 """
62 """
61 Gets a sorted tag list.
63 Gets a sorted tag list.
62 """
64 """
63
65
64 return self.tags.order_by('name')
66 return self.tags.order_by('name')
65
67
66 def bump(self):
68 def bump(self):
67 """
69 """
68 Bumps (moves to up) thread if possible.
70 Bumps (moves to up) thread if possible.
69 """
71 """
70
72
71 if self.can_bump():
73 if self.can_bump():
72 self.bump_time = timezone.now()
74 self.bump_time = self.last_edit_time
73
75
74 if self.get_reply_count() >= settings.MAX_POSTS_PER_THREAD:
76 if self.get_reply_count() >= settings.MAX_POSTS_PER_THREAD:
75 self.bumpable = False
77 self.bumpable = False
78 self.update_posts_time()
76
79
77 logger.info('Bumped thread %d' % self.id)
80 logger.info('Bumped thread %d' % self.id)
78
81
79 def get_reply_count(self):
82 def get_reply_count(self):
80 return self.get_replies().count()
83 return self.get_replies().count()
81
84
82 def get_images_count(self):
85 def get_images_count(self):
83 return self.get_replies().annotate(images_count=Count(
86 return self.get_replies().annotate(images_count=Count(
84 'images')).aggregate(Sum('images_count'))['images_count__sum']
87 'images')).aggregate(Sum('images_count'))['images_count__sum']
85
88
86 def can_bump(self):
89 def can_bump(self):
87 """
90 """
88 Checks if the thread can be bumped by replying to it.
91 Checks if the thread can be bumped by replying to it.
89 """
92 """
90
93
91 return self.bumpable
94 return self.bumpable and not self.archived
92
95
93 def get_last_replies(self):
96 def get_last_replies(self):
94 """
97 """
95 Gets several last replies, not including opening post
98 Gets several last replies, not including opening post
96 """
99 """
97
100
98 if settings.LAST_REPLIES_COUNT > 0:
101 if settings.LAST_REPLIES_COUNT > 0:
99 reply_count = self.get_reply_count()
102 reply_count = self.get_reply_count()
100
103
101 if reply_count > 0:
104 if reply_count > 0:
102 reply_count_to_show = min(settings.LAST_REPLIES_COUNT,
105 reply_count_to_show = min(settings.LAST_REPLIES_COUNT,
103 reply_count - 1)
106 reply_count - 1)
104 replies = self.get_replies()
107 replies = self.get_replies()
105 last_replies = replies[reply_count - reply_count_to_show:]
108 last_replies = replies[reply_count - reply_count_to_show:]
106
109
107 return last_replies
110 return last_replies
108
111
109 def get_skipped_replies_count(self):
112 def get_skipped_replies_count(self):
110 """
113 """
111 Gets number of posts between opening post and last replies.
114 Gets number of posts between opening post and last replies.
112 """
115 """
113 reply_count = self.get_reply_count()
116 reply_count = self.get_reply_count()
114 last_replies_count = min(settings.LAST_REPLIES_COUNT,
117 last_replies_count = min(settings.LAST_REPLIES_COUNT,
115 reply_count - 1)
118 reply_count - 1)
116 return reply_count - last_replies_count - 1
119 return reply_count - last_replies_count - 1
117
120
118 def get_replies(self, view_fields_only=False):
121 def get_replies(self, view_fields_only=False):
119 """
122 """
120 Gets sorted thread posts
123 Gets sorted thread posts
121 """
124 """
122
125
123 query = Post.objects.filter(threads__in=[self])
126 query = Post.objects.filter(threads__in=[self])
124 query = query.order_by('pub_time').prefetch_related('images', 'thread', 'threads')
127 query = query.order_by('pub_time').prefetch_related('images', 'thread', 'threads')
125 if view_fields_only:
128 if view_fields_only:
126 query = query.defer('poster_user_agent')
129 query = query.defer('poster_user_agent')
127 return query.all()
130 return query.all()
128
131
129 def get_replies_with_images(self, view_fields_only=False):
132 def get_replies_with_images(self, view_fields_only=False):
130 """
133 """
131 Gets replies that have at least one image attached
134 Gets replies that have at least one image attached
132 """
135 """
133
136
134 return self.get_replies(view_fields_only).annotate(images_count=Count(
137 return self.get_replies(view_fields_only).annotate(images_count=Count(
135 'images')).filter(images_count__gt=0)
138 'images')).filter(images_count__gt=0)
136
139
137 def add_tag(self, tag):
140 def add_tag(self, tag):
138 """
141 """
139 Connects thread to a tag and tag to a thread
142 Connects thread to a tag and tag to a thread
140 """
143 """
141
144
142 self.tags.add(tag)
145 self.tags.add(tag)
143
146
144 def get_opening_post(self, only_id=False):
147 def get_opening_post(self, only_id=False):
145 """
148 """
146 Gets the first post of the thread
149 Gets the first post of the thread
147 """
150 """
148
151
149 query = self.get_replies().order_by('pub_time')
152 query = self.get_replies().order_by('pub_time')
150 if only_id:
153 if only_id:
151 query = query.only('id')
154 query = query.only('id')
152 opening_post = query.first()
155 opening_post = query.first()
153
156
154 return opening_post
157 return opening_post
155
158
156 @cached_result
159 @cached_result
157 def get_opening_post_id(self):
160 def get_opening_post_id(self):
158 """
161 """
159 Gets ID of the first thread post.
162 Gets ID of the first thread post.
160 """
163 """
161
164
162 return self.get_opening_post(only_id=True).id
165 return self.get_opening_post(only_id=True).id
163
166
164 def __unicode__(self):
165 return str(self.id)
166
167 def get_pub_time(self):
167 def get_pub_time(self):
168 """
168 """
169 Gets opening post's pub time because thread does not have its own one.
169 Gets opening post's pub time because thread does not have its own one.
170 """
170 """
171
171
172 return self.get_opening_post().pub_time
172 return self.get_opening_post().pub_time
173
173
174 def delete(self, using=None):
174 def delete(self, using=None):
175 """
175 """
176 Deletes thread with all replies.
176 Deletes thread with all replies.
177 """
177 """
178
178
179 for reply in self.get_replies().all():
179 for reply in self.get_replies().all():
180 reply.delete()
180 reply.delete()
181
181
182 super(Thread, self).delete(using)
182 super(Thread, self).delete(using)
183
183
184 def __str__(self):
184 def __str__(self):
185 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
185 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
186
187 def get_tag_url_list(self):
188 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
189
190 def update_posts_time(self):
191 self.post_set.update(last_edit_time=self.last_edit_time)
@@ -1,93 +1,99 b''
1 /*
1 /*
2 @licstart The following is the entire license notice for the
2 @licstart The following is the entire license notice for the
3 JavaScript code in this page.
3 JavaScript code in this page.
4
4
5
5
6 Copyright (C) 2013 neko259
6 Copyright (C) 2013 neko259
7
7
8 The JavaScript code in this page is free software: you can
8 The JavaScript code in this page is free software: you can
9 redistribute it and/or modify it under the terms of the GNU
9 redistribute it and/or modify it under the terms of the GNU
10 General Public License (GNU GPL) as published by the Free Software
10 General Public License (GNU GPL) as published by the Free Software
11 Foundation, either version 3 of the License, or (at your option)
11 Foundation, either version 3 of the License, or (at your option)
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
13 without even the implied warranty of MERCHANTABILITY or FITNESS
13 without even the implied warranty of MERCHANTABILITY or FITNESS
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
15
15
16 As additional permission under GNU GPL version 3 section 7, you
16 As additional permission under GNU GPL version 3 section 7, you
17 may distribute non-source (e.g., minimized or compacted) forms of
17 may distribute non-source (e.g., minimized or compacted) forms of
18 that code without the copy of the GNU GPL normally required by
18 that code without the copy of the GNU GPL normally required by
19 section 4, provided you include this license notice and a URL
19 section 4, provided you include this license notice and a URL
20 through which recipients can access the Corresponding Source.
20 through which recipients can access the Corresponding Source.
21
21
22 @licend The above is the entire license notice
22 @licend The above is the entire license notice
23 for the JavaScript code in this page.
23 for the JavaScript code in this page.
24 */
24 */
25
25
26 if (window.Intl) {
26 var LOCALE = window.navigator.language;
27 var LOCALE = window.navigator.language;
27 var FORMATTER = new Intl.DateTimeFormat(
28 var FORMATTER = new Intl.DateTimeFormat(
28 LOCALE,
29 LOCALE,
29 {
30 {
30 weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
31 weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
31 hour: 'numeric', minute: '2-digit', second: '2-digit'
32 hour: 'numeric', minute: '2-digit', second: '2-digit'
32 }
33 }
33 );
34 );
35 }
34
36
35 /**
37 /**
36 * An email is a hidden file to prevent spam bots from posting. It has to be
38 * An email is a hidden file to prevent spam bots from posting. It has to be
37 * hidden.
39 * hidden.
38 */
40 */
39 function hideEmailFromForm() {
41 function hideEmailFromForm() {
40 $('.form-email').parent().parent().hide();
42 $('.form-email').parent().parent().hide();
41 }
43 }
42
44
43 /**
45 /**
44 * Highlight code blocks with code highlighter
46 * Highlight code blocks with code highlighter
45 */
47 */
46 function highlightCode(node) {
48 function highlightCode(node) {
47 node.find('pre code').each(function(i, e) {
49 node.find('pre code').each(function(i, e) {
48 hljs.highlightBlock(e);
50 hljs.highlightBlock(e);
49 });
51 });
50 }
52 }
51
53
52 /**
54 /**
53 * Translate timestamps to local ones for all <time> tags inside node.
55 * Translate timestamps to local ones for all <time> tags inside node.
54 */
56 */
55 function translate_time(node) {
57 function translate_time(node) {
58 if (window.Intl === null) {
59 return;
60 }
61
56 var elements;
62 var elements;
57
63
58 if (node === null) {
64 if (node === null) {
59 elements = $('time');
65 elements = $('time');
60 } else {
66 } else {
61 elements = node.find('time');
67 elements = node.find('time');
62 }
68 }
63
69
64 if (!elements.length) {
70 if (!elements.length) {
65 return;
71 return;
66 }
72 }
67
73
68 elements.each(function() {
74 elements.each(function() {
69 var element = $(this);
75 var element = $(this);
70 var dateAttr = element.attr('datetime');
76 var dateAttr = element.attr('datetime');
71 if (dateAttr) {
77 if (dateAttr) {
72 var date = new Date(dateAttr);
78 var date = new Date(dateAttr);
73 element.text(FORMATTER.format(date));
79 element.text(FORMATTER.format(date));
74 }
80 }
75 });
81 });
76 }
82 }
77
83
78 $( document ).ready(function() {
84 $( document ).ready(function() {
79 hideEmailFromForm();
85 hideEmailFromForm();
80
86
81 $("a[href='#top']").click(function() {
87 $("a[href='#top']").click(function() {
82 $("html, body").animate({ scrollTop: 0 }, "slow");
88 $("html, body").animate({ scrollTop: 0 }, "slow");
83 return false;
89 return false;
84 });
90 });
85
91
86 addImgPreview();
92 addImgPreview();
87
93
88 addRefLinkPreview();
94 addRefLinkPreview();
89
95
90 highlightCode($(document));
96 highlightCode($(document));
91
97
92 translate_time(null);
98 translate_time(null);
93 });
99 });
@@ -1,334 +1,335 b''
1 /*
1 /*
2 @licstart The following is the entire license notice for the
2 @licstart The following is the entire license notice for the
3 JavaScript code in this page.
3 JavaScript code in this page.
4
4
5
5
6 Copyright (C) 2013-2014 neko259
6 Copyright (C) 2013-2014 neko259
7
7
8 The JavaScript code in this page is free software: you can
8 The JavaScript code in this page is free software: you can
9 redistribute it and/or modify it under the terms of the GNU
9 redistribute it and/or modify it under the terms of the GNU
10 General Public License (GNU GPL) as published by the Free Software
10 General Public License (GNU GPL) as published by the Free Software
11 Foundation, either version 3 of the License, or (at your option)
11 Foundation, either version 3 of the License, or (at your option)
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
13 without even the implied warranty of MERCHANTABILITY or FITNESS
13 without even the implied warranty of MERCHANTABILITY or FITNESS
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
15
15
16 As additional permission under GNU GPL version 3 section 7, you
16 As additional permission under GNU GPL version 3 section 7, you
17 may distribute non-source (e.g., minimized or compacted) forms of
17 may distribute non-source (e.g., minimized or compacted) forms of
18 that code without the copy of the GNU GPL normally required by
18 that code without the copy of the GNU GPL normally required by
19 section 4, provided you include this license notice and a URL
19 section 4, provided you include this license notice and a URL
20 through which recipients can access the Corresponding Source.
20 through which recipients can access the Corresponding Source.
21
21
22 @licend The above is the entire license notice
22 @licend The above is the entire license notice
23 for the JavaScript code in this page.
23 for the JavaScript code in this page.
24 */
24 */
25
25
26 var wsUser = '';
26 var wsUser = '';
27
27
28 var unreadPosts = 0;
28 var unreadPosts = 0;
29 var documentOriginalTitle = '';
29 var documentOriginalTitle = '';
30
30
31 // Thread ID does not change, can be stored one time
31 // Thread ID does not change, can be stored one time
32 var threadId = $('div.thread').children('.post').first().attr('id');
32 var threadId = $('div.thread').children('.post').first().attr('id');
33
33
34 /**
34 /**
35 * Connect to websocket server and subscribe to thread updates. On any update we
35 * Connect to websocket server and subscribe to thread updates. On any update we
36 * request a thread diff.
36 * request a thread diff.
37 *
37 *
38 * @returns {boolean} true if connected, false otherwise
38 * @returns {boolean} true if connected, false otherwise
39 */
39 */
40 function connectWebsocket() {
40 function connectWebsocket() {
41 var metapanel = $('.metapanel')[0];
41 var metapanel = $('.metapanel')[0];
42
42
43 var wsHost = metapanel.getAttribute('data-ws-host');
43 var wsHost = metapanel.getAttribute('data-ws-host');
44 var wsPort = metapanel.getAttribute('data-ws-port');
44 var wsPort = metapanel.getAttribute('data-ws-port');
45
45
46 if (wsHost.length > 0 && wsPort.length > 0)
46 if (wsHost.length > 0 && wsPort.length > 0)
47 var centrifuge = new Centrifuge({
47 var centrifuge = new Centrifuge({
48 "url": 'ws://' + wsHost + ':' + wsPort + "/connection/websocket",
48 "url": 'ws://' + wsHost + ':' + wsPort + "/connection/websocket",
49 "project": metapanel.getAttribute('data-ws-project'),
49 "project": metapanel.getAttribute('data-ws-project'),
50 "user": wsUser,
50 "user": wsUser,
51 "timestamp": metapanel.getAttribute('data-last-update'),
51 "timestamp": metapanel.getAttribute('data-last-update'),
52 "token": metapanel.getAttribute('data-ws-token'),
52 "token": metapanel.getAttribute('data-ws-token'),
53 "debug": false
53 "debug": false
54 });
54 });
55
55
56 centrifuge.on('error', function(error_message) {
56 centrifuge.on('error', function(error_message) {
57 console.log("Error connecting to websocket server.");
57 console.log("Error connecting to websocket server.");
58 return false;
58 return false;
59 });
59 });
60
60
61 centrifuge.on('connect', function() {
61 centrifuge.on('connect', function() {
62 var channelName = 'thread:' + threadId;
62 var channelName = 'thread:' + threadId;
63 centrifuge.subscribe(channelName, function(message) {
63 centrifuge.subscribe(channelName, function(message) {
64 getThreadDiff();
64 getThreadDiff();
65 });
65 });
66
66
67 // For the case we closed the browser and missed some updates
67 // For the case we closed the browser and missed some updates
68 getThreadDiff();
68 getThreadDiff();
69 $('#autoupdate').hide();
69 $('#autoupdate').hide();
70 });
70 });
71
71
72 centrifuge.connect();
72 centrifuge.connect();
73
73
74 return true;
74 return true;
75 }
75 }
76
76
77 /**
77 /**
78 * Get diff of the posts from the current thread timestamp.
78 * Get diff of the posts from the current thread timestamp.
79 * This is required if the browser was closed and some post updates were
79 * This is required if the browser was closed and some post updates were
80 * missed.
80 * missed.
81 */
81 */
82 function getThreadDiff() {
82 function getThreadDiff() {
83 var lastUpdateTime = $('.metapanel').attr('data-last-update');
83 var lastUpdateTime = $('.metapanel').attr('data-last-update');
84
84
85 var diffUrl = '/api/diff_thread/' + threadId + '/' + lastUpdateTime + '/';
85 var diffUrl = '/api/diff_thread/' + threadId + '/' + lastUpdateTime + '/';
86
86
87 $.getJSON(diffUrl)
87 $.getJSON(diffUrl)
88 .success(function(data) {
88 .success(function(data) {
89 var addedPosts = data.added;
89 var addedPosts = data.added;
90
90
91 for (var i = 0; i < addedPosts.length; i++) {
91 for (var i = 0; i < addedPosts.length; i++) {
92 var postText = addedPosts[i];
92 var postText = addedPosts[i];
93 var post = $(postText);
93 var post = $(postText);
94
94
95 updatePost(post)
95 updatePost(post)
96 }
96 }
97
97
98 var updatedPosts = data.updated;
98 var updatedPosts = data.updated;
99
99
100 for (var i = 0; i < updatedPosts.length; i++) {
100 for (var i = 0; i < updatedPosts.length; i++) {
101 var postText = updatedPosts[i];
101 var postText = updatedPosts[i];
102 var post = $(postText);
102 var post = $(postText);
103
103
104 updatePost(post)
104 updatePost(post)
105 }
105 }
106
106
107 // TODO Process removed posts if any
107 // TODO Process removed posts if any
108 $('.metapanel').attr('data-last-update', data.last_update);
108 $('.metapanel').attr('data-last-update', data.last_update);
109 })
109 })
110 }
110 }
111
111
112 /**
112 /**
113 * Add or update the post on html page.
113 * Add or update the post on html page.
114 */
114 */
115 function updatePost(postHtml) {
115 function updatePost(postHtml) {
116 // This needs to be set on start because the page is scrolled after posts
116 // This needs to be set on start because the page is scrolled after posts
117 // are added or updated
117 // are added or updated
118 var bottom = isPageBottom();
118 var bottom = isPageBottom();
119
119
120 var post = $(postHtml);
120 var post = $(postHtml);
121
121
122 var threadBlock = $('div.thread');
122 var threadBlock = $('div.thread');
123
123
124 var lastUpdate = '';
124 var lastUpdate = '';
125
125
126 var postId = post.attr('id');
126 var postId = post.attr('id');
127
127
128 // If the post already exists, replace it. Otherwise add as a new one.
128 // If the post already exists, replace it. Otherwise add as a new one.
129 var existingPosts = threadBlock.children('.post[id=' + postId + ']');
129 var existingPosts = threadBlock.children('.post[id=' + postId + ']');
130
130
131 if (existingPosts.size() > 0) {
131 if (existingPosts.size() > 0) {
132 existingPosts.replaceWith(post);
132 existingPosts.replaceWith(post);
133 } else {
133 } else {
134 var threadPosts = threadBlock.children('.post');
134 var threadPosts = threadBlock.children('.post');
135 var lastPost = threadPosts.last();
135 var lastPost = threadPosts.last();
136
136
137 post.appendTo(lastPost.parent());
137 post.appendTo(lastPost.parent());
138
138
139 updateBumplimitProgress(1);
139 updateBumplimitProgress(1);
140 showNewPostsTitle(1);
140 showNewPostsTitle(1);
141
141
142 lastUpdate = post.children('.post-info').first()
142 lastUpdate = post.children('.post-info').first()
143 .children('.pub_time').first().html();
143 .children('.pub_time').first().html();
144
144
145 if (bottom) {
145 if (bottom) {
146 scrollToBottom();
146 scrollToBottom();
147 }
147 }
148 }
148 }
149
149
150 processNewPost(post);
150 processNewPost(post);
151 updateMetadataPanel(lastUpdate)
151 updateMetadataPanel(lastUpdate)
152 }
152 }
153
153
154 /**
154 /**
155 * Initiate a blinking animation on a node to show it was updated.
155 * Initiate a blinking animation on a node to show it was updated.
156 */
156 */
157 function blink(node) {
157 function blink(node) {
158 var blinkCount = 2;
158 var blinkCount = 2;
159
159
160 var nodeToAnimate = node;
160 var nodeToAnimate = node;
161 for (var i = 0; i < blinkCount; i++) {
161 for (var i = 0; i < blinkCount; i++) {
162 nodeToAnimate = nodeToAnimate.fadeTo('fast', 0.5).fadeTo('fast', 1.0);
162 nodeToAnimate = nodeToAnimate.fadeTo('fast', 0.5).fadeTo('fast', 1.0);
163 }
163 }
164 }
164 }
165
165
166 function isPageBottom() {
166 function isPageBottom() {
167 var scroll = $(window).scrollTop() / ($(document).height()
167 var scroll = $(window).scrollTop() / ($(document).height()
168 - $(window).height());
168 - $(window).height());
169
169
170 return scroll == 1
170 return scroll == 1
171 }
171 }
172
172
173 function initAutoupdate() {
173 function initAutoupdate() {
174 return connectWebsocket();
174 return connectWebsocket();
175 }
175 }
176
176
177 function getReplyCount() {
177 function getReplyCount() {
178 return $('.thread').children('.post').length
178 return $('.thread').children('.post').length
179 }
179 }
180
180
181 function getImageCount() {
181 function getImageCount() {
182 return $('.thread').find('img').length
182 return $('.thread').find('img').length
183 }
183 }
184
184
185 /**
185 /**
186 * Update post count, images count and last update time in the metadata
186 * Update post count, images count and last update time in the metadata
187 * panel.
187 * panel.
188 */
188 */
189 function updateMetadataPanel(lastUpdate) {
189 function updateMetadataPanel(lastUpdate) {
190 var replyCountField = $('#reply-count');
190 var replyCountField = $('#reply-count');
191 var imageCountField = $('#image-count');
191 var imageCountField = $('#image-count');
192
192
193 replyCountField.text(getReplyCount());
193 replyCountField.text(getReplyCount());
194 imageCountField.text(getImageCount());
194 imageCountField.text(getImageCount());
195
195
196 if (lastUpdate !== '') {
196 if (lastUpdate !== '') {
197 var lastUpdateField = $('#last-update');
197 var lastUpdateField = $('#last-update');
198 lastUpdateField.html(lastUpdate);
198 lastUpdateField.html(lastUpdate);
199 translate_time(lastUpdateField);
199 translate_time(lastUpdateField);
200 blink(lastUpdateField);
200 blink(lastUpdateField);
201 }
201 }
202
202
203 blink(replyCountField);
203 blink(replyCountField);
204 blink(imageCountField);
204 blink(imageCountField);
205 }
205 }
206
206
207 /**
207 /**
208 * Update bumplimit progress bar
208 * Update bumplimit progress bar
209 */
209 */
210 function updateBumplimitProgress(postDelta) {
210 function updateBumplimitProgress(postDelta) {
211 var progressBar = $('#bumplimit_progress');
211 var progressBar = $('#bumplimit_progress');
212 if (progressBar) {
212 if (progressBar) {
213 var postsToLimitElement = $('#left_to_limit');
213 var postsToLimitElement = $('#left_to_limit');
214
214
215 var oldPostsToLimit = parseInt(postsToLimitElement.text());
215 var oldPostsToLimit = parseInt(postsToLimitElement.text());
216 var postCount = getReplyCount();
216 var postCount = getReplyCount();
217 var bumplimit = postCount - postDelta + oldPostsToLimit;
217 var bumplimit = postCount - postDelta + oldPostsToLimit;
218
218
219 var newPostsToLimit = bumplimit - postCount;
219 var newPostsToLimit = bumplimit - postCount;
220 if (newPostsToLimit <= 0) {
220 if (newPostsToLimit <= 0) {
221 $('.bar-bg').remove();
221 $('.bar-bg').remove();
222 $('.thread').children('.post').addClass('dead_post');
223 } else {
222 } else {
224 postsToLimitElement.text(newPostsToLimit);
223 postsToLimitElement.text(newPostsToLimit);
225 progressBar.width((100 - postCount / bumplimit * 100.0) + '%');
224 progressBar.width((100 - postCount / bumplimit * 100.0) + '%');
226 }
225 }
227 }
226 }
228 }
227 }
229
228
230 /**
229 /**
231 * Show 'new posts' text in the title if the document is not visible to a user
230 * Show 'new posts' text in the title if the document is not visible to a user
232 */
231 */
233 function showNewPostsTitle(newPostCount) {
232 function showNewPostsTitle(newPostCount) {
234 if (document.hidden) {
233 if (document.hidden) {
235 if (documentOriginalTitle === '') {
234 if (documentOriginalTitle === '') {
236 documentOriginalTitle = document.title;
235 documentOriginalTitle = document.title;
237 }
236 }
238 unreadPosts = unreadPosts + newPostCount;
237 unreadPosts = unreadPosts + newPostCount;
239 document.title = '[' + unreadPosts + '] ' + documentOriginalTitle;
238 document.title = '[' + unreadPosts + '] ' + documentOriginalTitle;
240
239
241 document.addEventListener('visibilitychange', function() {
240 document.addEventListener('visibilitychange', function() {
242 if (documentOriginalTitle !== '') {
241 if (documentOriginalTitle !== '') {
243 document.title = documentOriginalTitle;
242 document.title = documentOriginalTitle;
244 documentOriginalTitle = '';
243 documentOriginalTitle = '';
245 unreadPosts = 0;
244 unreadPosts = 0;
246 }
245 }
247
246
248 document.removeEventListener('visibilitychange', null);
247 document.removeEventListener('visibilitychange', null);
249 });
248 });
250 }
249 }
251 }
250 }
252
251
253 /**
252 /**
254 * Clear all entered values in the form fields
253 * Clear all entered values in the form fields
255 */
254 */
256 function resetForm(form) {
255 function resetForm(form) {
257 form.find('input:text, input:password, input:file, select, textarea').val('');
256 form.find('input:text, input:password, input:file, select, textarea').val('');
258 form.find('input:radio, input:checkbox')
257 form.find('input:radio, input:checkbox')
259 .removeAttr('checked').removeAttr('selected');
258 .removeAttr('checked').removeAttr('selected');
260 $('.file_wrap').find('.file-thumb').remove();
259 $('.file_wrap').find('.file-thumb').remove();
261 }
260 }
262
261
263 /**
262 /**
264 * When the form is posted, this method will be run as a callback
263 * When the form is posted, this method will be run as a callback
265 */
264 */
266 function updateOnPost(response, statusText, xhr, form) {
265 function updateOnPost(response, statusText, xhr, form) {
267 var json = $.parseJSON(response);
266 var json = $.parseJSON(response);
268 var status = json.status;
267 var status = json.status;
269
268
270 showAsErrors(form, '');
269 showAsErrors(form, '');
271
270
272 if (status === 'ok') {
271 if (status === 'ok') {
273 resetForm(form);
272 resetForm(form);
274 getThreadDiff();
273 getThreadDiff();
275 } else {
274 } else {
276 var errors = json.errors;
275 var errors = json.errors;
277 for (var i = 0; i < errors.length; i++) {
276 for (var i = 0; i < errors.length; i++) {
278 var fieldErrors = errors[i];
277 var fieldErrors = errors[i];
279
278
280 var error = fieldErrors.errors;
279 var error = fieldErrors.errors;
281
280
282 showAsErrors(form, error);
281 showAsErrors(form, error);
283 }
282 }
284 }
283 }
285
284
286 scrollToBottom();
285 scrollToBottom();
287 }
286 }
288
287
289 /**
288 /**
290 * Show text in the errors row of the form.
289 * Show text in the errors row of the form.
291 * @param form
290 * @param form
292 * @param text
291 * @param text
293 */
292 */
294 function showAsErrors(form, text) {
293 function showAsErrors(form, text) {
295 form.children('.form-errors').remove();
294 form.children('.form-errors').remove();
296
295
297 if (text.length > 0) {
296 if (text.length > 0) {
298 var errorList = $('<div class="form-errors">' + text + '<div>');
297 var errorList = $('<div class="form-errors">' + text + '<div>');
299 errorList.appendTo(form);
298 errorList.appendTo(form);
300 }
299 }
301 }
300 }
302
301
303 /**
302 /**
304 * Run js methods that are usually run on the document, on the new post
303 * Run js methods that are usually run on the document, on the new post
305 */
304 */
306 function processNewPost(post) {
305 function processNewPost(post) {
307 addRefLinkPreview(post[0]);
306 addRefLinkPreview(post[0]);
308 highlightCode(post);
307 highlightCode(post);
309 translate_time(post);
308 translate_time(post);
310 blink(post);
309 blink(post);
311 }
310 }
312
311
313 $(document).ready(function(){
312 $(document).ready(function(){
314 if (initAutoupdate()) {
313 if (initAutoupdate()) {
315 // Post form data over AJAX
314 // Post form data over AJAX
316 var threadId = $('div.thread').children('.post').first().attr('id');
315 var threadId = $('div.thread').children('.post').first().attr('id');
317
316
318 var form = $('#form');
317 var form = $('#form');
319
318
319 if (form.length > 0) {
320 var options = {
320 var options = {
321 beforeSubmit: function(arr, $form, options) {
321 beforeSubmit: function(arr, $form, options) {
322 showAsErrors($('form'), gettext('Sending message...'));
322 showAsErrors($('form'), gettext('Sending message...'));
323 },
323 },
324 success: updateOnPost,
324 success: updateOnPost,
325 url: '/api/add_post/' + threadId + '/'
325 url: '/api/add_post/' + threadId + '/'
326 };
326 };
327
327
328 form.ajaxForm(options);
328 form.ajaxForm(options);
329
329
330 resetForm(form);
330 resetForm(form);
331 }
331 }
332 }
332
333
333 $('#autoupdate').click(getThreadDiff);
334 $('#autoupdate').click(getThreadDiff);
334 });
335 });
@@ -1,65 +1,63 b''
1 {% load staticfiles %}
1 {% load staticfiles %}
2 {% load i18n %}
2 {% load i18n %}
3 {% load l10n %}
3 {% load l10n %}
4 {% load static from staticfiles %}
4 {% load static from staticfiles %}
5
5
6 <!DOCTYPE html>
6 <!DOCTYPE html>
7 <html>
7 <html>
8 <head>
8 <head>
9 <link rel="stylesheet" type="text/css" href="{% static 'css/base.css' %}" media="all"/>
9 <link rel="stylesheet" type="text/css" href="{% static 'css/base.css' %}" media="all"/>
10 <link rel="stylesheet" type="text/css" href="{% static 'css/3party/highlight.css' %}" media="all"/>
10 <link rel="stylesheet" type="text/css" href="{% static 'css/3party/highlight.css' %}" media="all"/>
11 <link rel="stylesheet" type="text/css" href="{% static theme_css %}" media="all"/>
11 <link rel="stylesheet" type="text/css" href="{% static theme_css %}" media="all"/>
12
12
13 <link rel="alternate" type="application/rss+xml" href="rss/" title="{% trans 'Feed' %}"/>
13 <link rel="alternate" type="application/rss+xml" href="rss/" title="{% trans 'Feed' %}"/>
14
14
15 <link rel="icon" type="image/png"
15 <link rel="icon" type="image/png"
16 href="{% static 'favicon.png' %}">
16 href="{% static 'favicon.png' %}">
17
17
18 <meta name="viewport" content="width=device-width, initial-scale=1"/>
18 <meta name="viewport" content="width=device-width, initial-scale=1"/>
19 <meta charset="utf-8"/>
19 <meta charset="utf-8"/>
20
20
21 {% block head %}{% endblock %}
21 {% block head %}{% endblock %}
22 </head>
22 </head>
23 <body>
23 <body>
24 <script src="{% static 'js/jquery-2.0.1.min.js' %}"></script>
24 <script src="{% static 'js/jquery-2.0.1.min.js' %}"></script>
25 <script src="{% static 'js/jquery-ui-1.10.3.custom.min.js' %}"></script>
25 <script src="{% static 'js/jquery-ui-1.10.3.custom.min.js' %}"></script>
26 <script src="{% static 'js/jquery.mousewheel.js' %}"></script>
26 <script src="{% static 'js/jquery.mousewheel.js' %}"></script>
27 <script src="{% url 'js_info_dict' %}"></script>
27 <script src="{% url 'js_info_dict' %}"></script>
28
28
29 <div class="navigation_panel header">
29 <div class="navigation_panel header">
30 <a class="link" href="{% url 'index' %}">{% trans "All threads" %}</a>
30 <a class="link" href="{% url 'index' %}">{% trans "All threads" %}</a>
31 {% autoescape off %}
31 {% autoescape off %}
32 {% for tag in tags %}
32 {{ tags_str }},
33 {{ tag.get_view }},
34 {% endfor %}
35 {% endautoescape %}
33 {% endautoescape %}
36 <a href="{% url 'tags' %}" title="{% trans 'Tag management' %}"
34 <a href="{% url 'tags' %}" title="{% trans 'Tag management' %}"
37 >[...]</a>,
35 >[...]</a>,
38 <a href="{% url 'search' %}" title="{% trans 'Search' %}">[S]</a>.
36 <a href="{% url 'search' %}" title="{% trans 'Search' %}">[S]</a>.
39
37
40 {% if username %}
38 {% if username %}
41 <a href="{% url 'notifications' username %}" title="{% trans 'Notifications' %}"><b>{{ new_notifications_count }}</b> {% trans 'notifications' %}</a>.
39 <a href="{% url 'notifications' username %}" title="{% trans 'Notifications' %}"><b>{{ new_notifications_count }}</b> {% trans 'notifications' %}</a>.
42 {% endif %}
40 {% endif %}
43
41
44 <a class="link" href="{% url 'settings' %}">{% trans 'Settings' %}</a>
42 <a class="link" href="{% url 'settings' %}">{% trans 'Settings' %}</a>
45 </div>
43 </div>
46
44
47 {% block content %}{% endblock %}
45 {% block content %}{% endblock %}
48
46
49 <script src="{% static 'js/3party/highlight.min.js' %}"></script>
47 <script src="{% static 'js/3party/highlight.min.js' %}"></script>
50 <script src="{% static 'js/popup.js' %}"></script>
48 <script src="{% static 'js/popup.js' %}"></script>
51 <script src="{% static 'js/image.js' %}"></script>
49 <script src="{% static 'js/image.js' %}"></script>
52 <script src="{% static 'js/refpopup.js' %}"></script>
50 <script src="{% static 'js/refpopup.js' %}"></script>
53 <script src="{% static 'js/main.js' %}"></script>
51 <script src="{% static 'js/main.js' %}"></script>
54
52
55 <div class="navigation_panel footer">
53 <div class="navigation_panel footer">
56 {% block metapanel %}{% endblock %}
54 {% block metapanel %}{% endblock %}
57 [<a href="{% url 'admin:index' %}">{% trans 'Admin' %}</a>]
55 [<a href="{% url 'admin:index' %}">{% trans 'Admin' %}</a>]
58 {% with ppd=posts_per_day|floatformat:2 %}
56 {% with ppd=posts_per_day|floatformat:2 %}
59 {% blocktrans %}Speed: {{ ppd }} posts per day{% endblocktrans %}
57 {% blocktrans %}Speed: {{ ppd }} posts per day{% endblocktrans %}
60 {% endwith %}
58 {% endwith %}
61 <a class="link" href="#top" id="up">{% trans 'Up' %}</a>
59 <a class="link" href="#top" id="up">{% trans 'Up' %}</a>
62 </div>
60 </div>
63
61
64 </body>
62 </body>
65 </html>
63 </html>
@@ -1,102 +1,100 b''
1 {% load i18n %}
1 {% load i18n %}
2 {% load board %}
2 {% load board %}
3 {% load cache %}
3 {% load cache %}
4
4
5 {% get_current_language as LANGUAGE_CODE %}
5 {% get_current_language as LANGUAGE_CODE %}
6
6
7 {% if thread.archived %}
7 {% if thread.archived %}
8 <div class="post archive_post" id="{{ post.id }}">
8 <div class="post archive_post" id="{{ post.id }}">
9 {% elif bumpable %}
9 {% elif bumpable %}
10 <div class="post" id="{{ post.id }}">
10 <div class="post" id="{{ post.id }}">
11 {% else %}
11 {% else %}
12 <div class="post dead_post" id="{{ post.id }}">
12 <div class="post dead_post" id="{{ post.id }}">
13 {% endif %}
13 {% endif %}
14
14
15 <div class="post-info">
15 <div class="post-info">
16 <a class="post_id" href="{{ post.get_url }}"
16 <a class="post_id" href="{{ post.get_url }}"
17 {% if not truncated and not thread.archived %}
17 {% if not truncated and not thread.archived %}
18 onclick="javascript:addQuickReply('{{ post.id }}'); return false;"
18 onclick="javascript:addQuickReply('{{ post.id }}'); return false;"
19 title="{% trans 'Quote' %}" {% endif %}>({{ post.get_absolute_id }})</a>
19 title="{% trans 'Quote' %}" {% endif %}>({{ post.get_absolute_id }})</a>
20 <span class="title">{{ post.title }}</span>
20 <span class="title">{{ post.title }}</span>
21 <span class="pub_time"><time datetime="{{ post.pub_time|date:'c' }}">{{ post.pub_time|date:'r' }}</time></span>
21 <span class="pub_time"><time datetime="{{ post.pub_time|date:'c' }}">{{ post.pub_time|date:'r' }}</time></span>
22 {% comment %}
22 {% comment %}
23 Thread death time needs to be shown only if the thread is alredy archived
23 Thread death time needs to be shown only if the thread is alredy archived
24 and this is an opening post (thread death time) or a post for popup
24 and this is an opening post (thread death time) or a post for popup
25 (we don't see OP here so we show the death time in the post itself).
25 (we don't see OP here so we show the death time in the post itself).
26 {% endcomment %}
26 {% endcomment %}
27 {% if thread.archived %}
27 {% if thread.archived %}
28 {% if is_opening %}
28 {% if is_opening %}
29 β€” {{ thread.bump_time }}
29 β€” <time datetime="{{ thread.bump_time|date:'c' }}">{{ thread.bump_time|date:'r' }}</time>
30 {% endif %}
30 {% endif %}
31 {% endif %}
31 {% endif %}
32 {% if is_opening and need_open_link %}
32 {% if is_opening and need_open_link %}
33 {% if thread.archived %}
33 {% if thread.archived %}
34 [<a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>]
34 [<a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>]
35 {% else %}
35 {% else %}
36 [<a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>]
36 [<a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>]
37 {% endif %}
37 {% endif %}
38 {% endif %}
38 {% endif %}
39
39
40 {% if post.global_id %}
40 {% if post.global_id %}
41 <a class="global-id" href="
41 <a class="global-id" href="
42 {% url 'post_sync_data' post.id %}"> [RAW] </a>
42 {% url 'post_sync_data' post.id %}"> [RAW] </a>
43 {% endif %}
43 {% endif %}
44
44
45 {% if moderator %}
45 {% if moderator %}
46 <span class="moderator_info">
46 <span class="moderator_info">
47 [<a href="{% url 'admin:boards_post_change' post.id %}">{% trans 'Edit' %}</a>]
47 [<a href="{% url 'admin:boards_post_change' post.id %}">{% trans 'Edit' %}</a>]
48 {% if is_opening %}
48 {% if is_opening %}
49 [<a href="{% url 'admin:boards_thread_change' thread.id %}">{% trans 'Edit thread' %}</a>]
49 [<a href="{% url 'admin:boards_thread_change' thread.id %}">{% trans 'Edit thread' %}</a>]
50 {% endif %}
50 {% endif %}
51 </span>
51 </span>
52 {% endif %}
52 {% endif %}
53 </div>
53 </div>
54 {% comment %}
54 {% comment %}
55 Post images. Currently only 1 image can be posted and shown, but post model
55 Post images. Currently only 1 image can be posted and shown, but post model
56 supports multiple.
56 supports multiple.
57 {% endcomment %}
57 {% endcomment %}
58 {% if post.images.exists %}
58 {% if post.images.exists %}
59 {% with post.images.all.0 as image %}
59 {% with post.images.all.0 as image %}
60 {% autoescape off %}
60 {% autoescape off %}
61 {{ image.get_view }}
61 {{ image.get_view }}
62 {% endautoescape %}
62 {% endautoescape %}
63 {% endwith %}
63 {% endwith %}
64 {% endif %}
64 {% endif %}
65 {% comment %}
65 {% comment %}
66 Post message (text)
66 Post message (text)
67 {% endcomment %}
67 {% endcomment %}
68 <div class="message">
68 <div class="message">
69 {% autoescape off %}
69 {% autoescape off %}
70 {% if truncated %}
70 {% if truncated %}
71 {{ post.get_text|truncatewords_html:50 }}
71 {{ post.get_text|truncatewords_html:50 }}
72 {% else %}
72 {% else %}
73 {{ post.get_text }}
73 {{ post.get_text }}
74 {% endif %}
74 {% endif %}
75 {% endautoescape %}
75 {% endautoescape %}
76 {% if post.is_referenced %}
76 {% if post.is_referenced %}
77 <div class="refmap">
77 <div class="refmap">
78 {% autoescape off %}
78 {% autoescape off %}
79 {% trans "Replies" %}: {{ post.refmap }}
79 {% trans "Replies" %}: {{ post.refmap }}
80 {% endautoescape %}
80 {% endautoescape %}
81 </div>
81 </div>
82 {% endif %}
82 {% endif %}
83 </div>
83 </div>
84 {% comment %}
84 {% comment %}
85 Thread metadata: counters, tags etc
85 Thread metadata: counters, tags etc
86 {% endcomment %}
86 {% endcomment %}
87 {% if is_opening %}
87 {% if is_opening %}
88 <div class="metadata">
88 <div class="metadata">
89 {% if is_opening and need_open_link %}
89 {% if is_opening and need_open_link %}
90 {{ thread.get_reply_count }} {% trans 'messages' %},
90 {{ thread.get_reply_count }} {% trans 'messages' %},
91 {{ thread.get_images_count }} {% trans 'images' %}.
91 {{ thread.get_images_count }} {% trans 'images' %}.
92 {% endif %}
92 {% endif %}
93 <span class="tags">
93 <span class="tags">
94 {% autoescape off %}
94 {% autoescape off %}
95 {% for tag in thread.get_tags %}
95 {{ thread.get_tag_url_list }}
96 {{ tag.get_view }}{% if not forloop.last %},{% endif %}
97 {% endfor %}
98 {% endautoescape %}
96 {% endautoescape %}
99 </span>
97 </span>
100 </div>
98 </div>
101 {% endif %}
99 {% endif %}
102 </div>
100 </div>
@@ -1,188 +1,190 b''
1 {% extends "boards/base.html" %}
1 {% extends "boards/base.html" %}
2
2
3 {% load i18n %}
3 {% load i18n %}
4 {% load cache %}
4 {% load cache %}
5 {% load board %}
5 {% load board %}
6 {% load static %}
6 {% load static %}
7
7
8 {% block head %}
8 {% block head %}
9 {% if tag %}
9 {% if tag %}
10 <title>{{ tag.name }} - {{ site_name }}</title>
10 <title>{{ tag.name }} - {{ site_name }}</title>
11 {% else %}
11 {% else %}
12 <title>{{ site_name }}</title>
12 <title>{{ site_name }}</title>
13 {% endif %}
13 {% endif %}
14
14
15 {% if current_page.has_previous %}
15 {% if current_page.has_previous %}
16 <link rel="prev" href="
16 <link rel="prev" href="
17 {% if tag %}
17 {% if tag %}
18 {% url "tag" tag_name=tag.name page=current_page.previous_page_number %}
18 {% url "tag" tag_name=tag.name page=current_page.previous_page_number %}
19 {% else %}
19 {% else %}
20 {% url "index" page=current_page.previous_page_number %}
20 {% url "index" page=current_page.previous_page_number %}
21 {% endif %}
21 {% endif %}
22 " />
22 " />
23 {% endif %}
23 {% endif %}
24 {% if current_page.has_next %}
24 {% if current_page.has_next %}
25 <link rel="next" href="
25 <link rel="next" href="
26 {% if tag %}
26 {% if tag %}
27 {% url "tag" tag_name=tag.name page=current_page.next_page_number %}
27 {% url "tag" tag_name=tag.name page=current_page.next_page_number %}
28 {% else %}
28 {% else %}
29 {% url "index" page=current_page.next_page_number %}
29 {% url "index" page=current_page.next_page_number %}
30 {% endif %}
30 {% endif %}
31 " />
31 " />
32 {% endif %}
32 {% endif %}
33
33
34 {% endblock %}
34 {% endblock %}
35
35
36 {% block content %}
36 {% block content %}
37
37
38 {% get_current_language as LANGUAGE_CODE %}
38 {% get_current_language as LANGUAGE_CODE %}
39
39
40 {% if tag %}
40 {% if tag %}
41 <div class="tag_info">
41 <div class="tag_info">
42 <h2>
42 <h2>
43 {% if is_favorite %}
43 {% if is_favorite %}
44 <a href="{% url 'tag' tag.name %}?method=unsubscribe&next={{ request.path }}"
44 <a href="{% url 'tag' tag.name %}?method=unsubscribe&next={{ request.path }}"
45 class="fav" rel="nofollow">β˜…</a>
45 class="fav" rel="nofollow">β˜…</a>
46 {% else %}
46 {% else %}
47 <a href="{% url 'tag' tag.name %}?method=subscribe&next={{ request.path }}"
47 <a href="{% url 'tag' tag.name %}?method=subscribe&next={{ request.path }}"
48 class="not_fav" rel="nofollow">β˜…</a>
48 class="not_fav" rel="nofollow">β˜…</a>
49 {% endif %}
49 {% endif %}
50 {% if is_hidden %}
50 {% if is_hidden %}
51 <a href="{% url 'tag' tag.name %}?method=unhide&next={{ request.path }}"
51 <a href="{% url 'tag' tag.name %}?method=unhide&next={{ request.path }}"
52 title="{% trans 'Show tag' %}"
52 title="{% trans 'Show tag' %}"
53 class="fav" rel="nofollow">H</a>
53 class="fav" rel="nofollow">H</a>
54 {% else %}
54 {% else %}
55 <a href="{% url 'tag' tag.name %}?method=hide&next={{ request.path }}"
55 <a href="{% url 'tag' tag.name %}?method=hide&next={{ request.path }}"
56 title="{% trans 'Hide tag' %}"
56 title="{% trans 'Hide tag' %}"
57 class="not_fav" rel="nofollow">H</a>
57 class="not_fav" rel="nofollow">H</a>
58 {% endif %}
58 {% endif %}
59 {% autoescape off %}
59 {% autoescape off %}
60 {{ tag.get_view }}
60 {{ tag.get_view }}
61 {% endautoescape %}
61 {% endautoescape %}
62 {% if moderator %}
62 {% if moderator %}
63 <span class="moderator_info">[<a href="{% url 'admin:boards_tag_change' tag.id %}">{% trans 'Edit tag' %}</a>]</span>
63 <span class="moderator_info">[<a href="{% url 'admin:boards_tag_change' tag.id %}">{% trans 'Edit tag' %}</a>]</span>
64 {% endif %}
64 {% endif %}
65 </h2>
65 </h2>
66 <p>{% blocktrans with thread_count=tag.get_thread_count post_count=tag.get_post_count %}This tag has {{ thread_count }} threads and {{ post_count }} posts.{% endblocktrans %}</p>
66 <p>{% blocktrans with thread_count=tag.get_thread_count post_count=tag.get_post_count %}This tag has {{ thread_count }} threads and {{ post_count }} posts.{% endblocktrans %}</p>
67 </div>
67 </div>
68 {% endif %}
68 {% endif %}
69
69
70 {% if threads %}
70 {% if threads %}
71 {% if current_page.has_previous %}
71 {% if current_page.has_previous %}
72 <div class="page_link">
72 <div class="page_link">
73 <a href="
73 <a href="
74 {% if tag %}
74 {% if tag %}
75 {% url "tag" tag_name=tag.name page=current_page.previous_page_number %}
75 {% url "tag" tag_name=tag.name page=current_page.previous_page_number %}
76 {% else %}
76 {% else %}
77 {% url "index" page=current_page.previous_page_number %}
77 {% url "index" page=current_page.previous_page_number %}
78 {% endif %}
78 {% endif %}
79 ">{% trans "Previous page" %}</a>
79 ">{% trans "Previous page" %}</a>
80 </div>
80 </div>
81 {% endif %}
81 {% endif %}
82
82
83 {% for thread in threads %}
83 {% for thread in threads %}
84 {% cache 600 thread_short thread.id thread.last_edit_time moderator LANGUAGE_CODE %}
84 {% cache 600 thread_short thread.id thread.last_edit_time moderator LANGUAGE_CODE %}
85 <div class="thread">
85 <div class="thread">
86 {% post_view thread.get_opening_post moderator is_opening=True thread=thread truncated=True need_open_link=True %}
86 {% post_view thread.get_opening_post moderator is_opening=True thread=thread truncated=True need_open_link=True %}
87 {% if not thread.archived %}
87 {% if not thread.archived %}
88 {% with last_replies=thread.get_last_replies %}
88 {% with last_replies=thread.get_last_replies %}
89 {% if last_replies %}
89 {% if last_replies %}
90 {% if thread.get_skipped_replies_count %}
90 {% with skipped_replies_count=thread.get_skipped_replies_count %}
91 {% if skipped_replies_count %}
91 <div class="skipped_replies">
92 <div class="skipped_replies">
92 <a href="{% url 'thread' thread.get_opening_post.id %}">
93 <a href="{% url 'thread' thread.get_opening_post_id %}">
93 {% blocktrans with count=thread.get_skipped_replies_count %}Skipped {{ count }} replies. Open thread to see all replies.{% endblocktrans %}
94 {% blocktrans with count=skipped_replies_count %}Skipped {{ count }} replies. Open thread to see all replies.{% endblocktrans %}
94 </a>
95 </a>
95 </div>
96 </div>
96 {% endif %}
97 {% endif %}
98 {% endwith %}
97 <div class="last-replies">
99 <div class="last-replies">
98 {% for post in last_replies %}
100 {% for post in last_replies %}
99 {% post_view post is_opening=False moderator=moderator truncated=True %}
101 {% post_view post is_opening=False moderator=moderator truncated=True %}
100 {% endfor %}
102 {% endfor %}
101 </div>
103 </div>
102 {% endif %}
104 {% endif %}
103 {% endwith %}
105 {% endwith %}
104 {% endif %}
106 {% endif %}
105 </div>
107 </div>
106 {% endcache %}
108 {% endcache %}
107 {% endfor %}
109 {% endfor %}
108
110
109 {% if current_page.has_next %}
111 {% if current_page.has_next %}
110 <div class="page_link">
112 <div class="page_link">
111 <a href="
113 <a href="
112 {% if tag %}
114 {% if tag %}
113 {% url "tag" tag_name=tag.name page=current_page.next_page_number %}
115 {% url "tag" tag_name=tag.name page=current_page.next_page_number %}
114 {% else %}
116 {% else %}
115 {% url "index" page=current_page.next_page_number %}
117 {% url "index" page=current_page.next_page_number %}
116 {% endif %}
118 {% endif %}
117 ">{% trans "Next page" %}</a>
119 ">{% trans "Next page" %}</a>
118 </div>
120 </div>
119 {% endif %}
121 {% endif %}
120 {% else %}
122 {% else %}
121 <div class="post">
123 <div class="post">
122 {% trans 'No threads exist. Create the first one!' %}</div>
124 {% trans 'No threads exist. Create the first one!' %}</div>
123 {% endif %}
125 {% endif %}
124
126
125 <div class="post-form-w">
127 <div class="post-form-w">
126 <script src="{% static 'js/panel.js' %}"></script>
128 <script src="{% static 'js/panel.js' %}"></script>
127 <div class="post-form">
129 <div class="post-form">
128 <div class="form-title">{% trans "Create new thread" %}</div>
130 <div class="form-title">{% trans "Create new thread" %}</div>
129 <div class="swappable-form-full">
131 <div class="swappable-form-full">
130 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
132 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
131 {{ form.as_div }}
133 {{ form.as_div }}
132 <div class="form-submit">
134 <div class="form-submit">
133 <input type="submit" value="{% trans "Post" %}"/>
135 <input type="submit" value="{% trans "Post" %}"/>
134 </div>
136 </div>
135 (ctrl-enter)
137 (ctrl-enter)
136 </form>
138 </form>
137 </div>
139 </div>
138 <div>
140 <div>
139 {% trans 'Tags must be delimited by spaces. Text or image is required.' %}
141 {% trans 'Tags must be delimited by spaces. Text or image is required.' %}
140 </div>
142 </div>
141 <div><a href="{% url "staticpage" name="help" %}">
143 <div><a href="{% url "staticpage" name="help" %}">
142 {% trans 'Text syntax' %}</a></div>
144 {% trans 'Text syntax' %}</a></div>
143 </div>
145 </div>
144 </div>
146 </div>
145
147
146 <script src="{% static 'js/form.js' %}"></script>
148 <script src="{% static 'js/form.js' %}"></script>
147
149
148 {% endblock %}
150 {% endblock %}
149
151
150 {% block metapanel %}
152 {% block metapanel %}
151
153
152 <span class="metapanel">
154 <span class="metapanel">
153 <b><a href="{% url "authors" %}">{{ site_name }}</a> {{ version }}</b>
155 <b><a href="{% url "authors" %}">{{ site_name }}</a> {{ version }}</b>
154 {% trans "Pages:" %}
156 {% trans "Pages:" %}
155 <a href="
157 <a href="
156 {% if tag %}
158 {% if tag %}
157 {% url "tag" tag_name=tag.name page=paginator.page_range|first %}
159 {% url "tag" tag_name=tag.name page=paginator.page_range|first %}
158 {% else %}
160 {% else %}
159 {% url "index" page=paginator.page_range|first %}
161 {% url "index" page=paginator.page_range|first %}
160 {% endif %}
162 {% endif %}
161 ">&lt;&lt;</a>
163 ">&lt;&lt;</a>
162 [
164 [
163 {% for page in paginator.center_range %}
165 {% for page in paginator.center_range %}
164 <a
166 <a
165 {% ifequal page current_page.number %}
167 {% ifequal page current_page.number %}
166 class="current_page"
168 class="current_page"
167 {% endifequal %}
169 {% endifequal %}
168 href="
170 href="
169 {% if tag %}
171 {% if tag %}
170 {% url "tag" tag_name=tag.name page=page %}
172 {% url "tag" tag_name=tag.name page=page %}
171 {% else %}
173 {% else %}
172 {% url "index" page=page %}
174 {% url "index" page=page %}
173 {% endif %}
175 {% endif %}
174 ">{{ page }}</a>
176 ">{{ page }}</a>
175 {% if not forloop.last %},{% endif %}
177 {% if not forloop.last %},{% endif %}
176 {% endfor %}
178 {% endfor %}
177 ]
179 ]
178 <a href="
180 <a href="
179 {% if tag %}
181 {% if tag %}
180 {% url "tag" tag_name=tag.name page=paginator.page_range|last %}
182 {% url "tag" tag_name=tag.name page=paginator.page_range|last %}
181 {% else %}
183 {% else %}
182 {% url "index" page=paginator.page_range|last %}
184 {% url "index" page=paginator.page_range|last %}
183 {% endif %}
185 {% endif %}
184 ">&gt;&gt;</a>
186 ">&gt;&gt;</a>
185 [<a href="rss/">RSS</a>]
187 [<a href="rss/">RSS</a>]
186 </span>
188 </span>
187
189
188 {% endblock %}
190 {% endblock %}
@@ -1,93 +1,93 b''
1 {% extends "boards/base.html" %}
1 {% extends "boards/base.html" %}
2
2
3 {% load i18n %}
3 {% load i18n %}
4 {% load cache %}
4 {% load cache %}
5 {% load static from staticfiles %}
5 {% load static from staticfiles %}
6 {% load board %}
6 {% load board %}
7
7
8 {% block head %}
8 {% block head %}
9 <title>{{ opening_post.get_title|striptags|truncatewords:10 }}
9 <title>{{ opening_post.get_title|striptags|truncatewords:10 }}
10 - {{ site_name }}</title>
10 - {{ site_name }}</title>
11 {% endblock %}
11 {% endblock %}
12
12
13 {% block content %}
13 {% block content %}
14 {% get_current_language as LANGUAGE_CODE %}
14 {% get_current_language as LANGUAGE_CODE %}
15
15
16 {% cache 600 thread_view thread.id thread.last_edit_time moderator LANGUAGE_CODE %}
16 {% cache 600 thread_view thread.id thread.last_edit_time moderator LANGUAGE_CODE %}
17
17
18 <div class="image-mode-tab">
18 <div class="image-mode-tab">
19 <a class="current_mode" href="{% url 'thread' opening_post.id %}">{% trans 'Normal mode' %}</a>,
19 <a class="current_mode" href="{% url 'thread' opening_post.id %}">{% trans 'Normal mode' %}</a>,
20 <a href="{% url 'thread_gallery' opening_post.id %}">{% trans 'Gallery mode' %}</a>
20 <a href="{% url 'thread_gallery' opening_post.id %}">{% trans 'Gallery mode' %}</a>
21 </div>
21 </div>
22
22
23 {% if bumpable %}
23 {% if bumpable %}
24 <div class="bar-bg">
24 <div class="bar-bg">
25 <div class="bar-value" style="width:{{ bumplimit_progress }}%" id="bumplimit_progress">
25 <div class="bar-value" style="width:{{ bumplimit_progress }}%" id="bumplimit_progress">
26 </div>
26 </div>
27 <div class="bar-text">
27 <div class="bar-text">
28 <span id="left_to_limit">{{ posts_left }}</span> {% trans 'posts to bumplimit' %}
28 <span id="left_to_limit">{{ posts_left }}</span> {% trans 'posts to bumplimit' %}
29 </div>
29 </div>
30 </div>
30 </div>
31 {% endif %}
31 {% endif %}
32
32
33 <div class="thread">
33 <div class="thread">
34 {% with can_bump=thread.can_bump %}
34 {% with can_bump=thread.can_bump %}
35 {% for post in thread.get_replies %}
35 {% for post in thread.get_replies %}
36 {% with is_opening=forloop.first %}
36 {% with is_opening=forloop.first %}
37 {% post_view post moderator=moderator is_opening=is_opening bumpable=can_bump opening_post_id=opening_post.id %}
37 {% post_view post moderator=moderator is_opening=is_opening bumpable=can_bump opening_post_id=opening_post.id %}
38 {% endwith %}
38 {% endwith %}
39 {% endfor %}
39 {% endfor %}
40 {% endwith %}
40 {% endwith %}
41 </div>
41 </div>
42
42
43 {% if not thread.archived %}
43 {% if not thread.archived %}
44 <div class="post-form-w">
44 <div class="post-form-w">
45 <script src="{% static 'js/panel.js' %}"></script>
45 <script src="{% static 'js/panel.js' %}"></script>
46 <div class="form-title">{% trans "Reply to thread" %} #{{ opening_post.id }}</div>
46 <div class="form-title">{% trans "Reply to thread" %} #{{ opening_post.id }}</div>
47 <div class="post-form" id="compact-form">
47 <div class="post-form" id="compact-form">
48 <div class="swappable-form-full">
48 <div class="swappable-form-full">
49 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
49 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
50 <div class="compact-form-text"></div>
50 <div class="compact-form-text"></div>
51 {{ form.as_div }}
51 {{ form.as_div }}
52 <div class="form-submit">
52 <div class="form-submit">
53 <input type="submit" value="{% trans "Post" %}"/>
53 <input type="submit" value="{% trans "Post" %}"/>
54 </div>
54 </div>
55 (ctrl-enter)
55 (ctrl-enter)
56 </form>
56 </form>
57 </div>
57 </div>
58 <div><a href="{% url "staticpage" name="help" %}">
58 <div><a href="{% url "staticpage" name="help" %}">
59 {% trans 'Text syntax' %}</a></div>
59 {% trans 'Text syntax' %}</a></div>
60 </div>
60 </div>
61 </div>
61 </div>
62
62
63 <script src="{% static 'js/jquery.form.min.js' %}"></script>
63 <script src="{% static 'js/jquery.form.min.js' %}"></script>
64 <script src="{% static 'js/thread_update.js' %}"></script>
65 <script src="{% static 'js/3party/centrifuge.js' %}"></script>
66 {% endif %}
64 {% endif %}
67
65
68 <script src="{% static 'js/form.js' %}"></script>
66 <script src="{% static 'js/form.js' %}"></script>
69 <script src="{% static 'js/thread.js' %}"></script>
67 <script src="{% static 'js/thread.js' %}"></script>
68 <script src="{% static 'js/thread_update.js' %}"></script>
69 <script src="{% static 'js/3party/centrifuge.js' %}"></script>
70
70
71 {% endcache %}
71 {% endcache %}
72 {% endblock %}
72 {% endblock %}
73
73
74 {% block metapanel %}
74 {% block metapanel %}
75
75
76 {% get_current_language as LANGUAGE_CODE %}
76 {% get_current_language as LANGUAGE_CODE %}
77
77
78 <span class="metapanel"
78 <span class="metapanel"
79 data-last-update="{{ last_update }}"
79 data-last-update="{{ last_update }}"
80 data-ws-token="{{ ws_token }}"
80 data-ws-token="{{ ws_token }}"
81 data-ws-project="{{ ws_project }}"
81 data-ws-project="{{ ws_project }}"
82 data-ws-host="{{ ws_host }}"
82 data-ws-host="{{ ws_host }}"
83 data-ws-port="{{ ws_port }}">
83 data-ws-port="{{ ws_port }}">
84 {% cache 600 thread_meta thread.last_edit_time moderator LANGUAGE_CODE %}
84 {% cache 600 thread_meta thread.last_edit_time moderator LANGUAGE_CODE %}
85 <button id="autoupdate">{% trans 'Update' %}</button>
85 <button id="autoupdate">{% trans 'Update' %}</button>
86 <span id="reply-count">{{ thread.get_reply_count }}</span>/{{ max_replies }} {% trans 'messages' %},
86 <span id="reply-count">{{ thread.get_reply_count }}</span>/{{ max_replies }} {% trans 'messages' %},
87 <span id="image-count">{{ thread.get_images_count }}</span> {% trans 'images' %}.
87 <span id="image-count">{{ thread.get_images_count }}</span> {% trans 'images' %}.
88 {% trans 'Last update: ' %}<span id="last-update"><time datetime="{{ thread.last_edit_time|date:'c' }}">{{ thread.last_edit_time|date:'r' }}</time></span>
88 {% trans 'Last update: ' %}<span id="last-update"><time datetime="{{ thread.last_edit_time|date:'c' }}">{{ thread.last_edit_time|date:'r' }}</time></span>
89 [<a href="rss/">RSS</a>]
89 [<a href="rss/">RSS</a>]
90 {% endcache %}
90 {% endcache %}
91 </span>
91 </span>
92
92
93 {% endblock %}
93 {% endblock %}
@@ -1,145 +1,143 b''
1 import re
1 import re
2 from django.shortcuts import get_object_or_404
2 from django.shortcuts import get_object_or_404
3 from django import template
3 from django import template
4
4
5 ELLIPSIZER = '...'
5 ELLIPSIZER = '...'
6
6
7 REGEX_LINES = re.compile(r'(<div class="br"></div>)', re.U | re.S)
7 REGEX_LINES = re.compile(r'(<div class="br"></div>)', re.U | re.S)
8 REGEX_TAG = re.compile(r'<(/)?([^ ]+?)(?:(\s*/)| .*?)?>', re.S)
8 REGEX_TAG = re.compile(r'<(/)?([^ ]+?)(?:(\s*/)| .*?)?>', re.S)
9
9
10 IMG_ACTION_URL = '[<a href="{}">{}</a>]'
11
10
12
11 register = template.Library()
13 register = template.Library()
12
14
13 actions = [
15 actions = [
14 {
16 {
15 'name': 'google',
17 'name': 'google',
16 'link': 'http://google.com/searchbyimage?image_url=%s',
18 'link': 'http://google.com/searchbyimage?image_url=%s',
17 },
19 },
18 {
20 {
19 'name': 'iqdb',
21 'name': 'iqdb',
20 'link': 'http://iqdb.org/?url=%s',
22 'link': 'http://iqdb.org/?url=%s',
21 },
23 },
22 ]
24 ]
23
25
24
26
25 @register.simple_tag(name='post_url')
27 @register.simple_tag(name='post_url')
26 def post_url(*args, **kwargs):
28 def post_url(*args, **kwargs):
27 post_id = args[0]
29 post_id = args[0]
28
30
29 post = get_object_or_404('Post', id=post_id)
31 post = get_object_or_404('Post', id=post_id)
30
32
31 return post.get_url()
33 return post.get_url()
32
34
33
35
34 @register.simple_tag(name='image_actions')
36 @register.simple_tag(name='image_actions')
35 def image_actions(*args, **kwargs):
37 def image_actions(*args, **kwargs):
36 image_link = args[0]
38 image_link = args[0]
37 if len(args) > 1:
39 if len(args) > 1:
38 image_link = 'http://' + args[1] + image_link # TODO https?
40 image_link = 'http://' + args[1] + image_link # TODO https?
39
41
40 result = ''
42 return ', '.join([IMG_ACTION_URL.format(
41
43 action['link'] % image_link, action['name'])for action in actions])
42 for action in actions:
43 result += '[<a href="' + action['link'] % image_link + '">' + \
44 action['name'] + '</a>]'
45
46 return result
47
44
48
45
49 # TODO Use get_view of a post instead of this
46 # TODO Use get_view of a post instead of this
50 @register.inclusion_tag('boards/post.html', name='post_view')
47 @register.inclusion_tag('boards/post.html', name='post_view')
51 def post_view(post, moderator=False, need_open_link=False, truncated=False,
48 def post_view(post, moderator=False, need_open_link=False, truncated=False,
52 **kwargs):
49 **kwargs):
53 """
50 """
54 Get post
51 Get post
55 """
52 """
56
53
57 if 'is_opening' in kwargs:
54 if 'is_opening' in kwargs:
58 is_opening = kwargs['is_opening']
55 is_opening = kwargs['is_opening']
59 else:
56 else:
60 is_opening = post.is_opening()
57 is_opening = post.is_opening()
61
58
62 thread = post.get_thread()
59 thread = post.get_thread()
63
60
64 if 'can_bump' in kwargs:
61 if 'can_bump' in kwargs:
65 can_bump = kwargs['can_bump']
62 can_bump = kwargs['can_bump']
66 else:
63 else:
67 can_bump = thread.can_bump()
64 can_bump = thread.can_bump()
68
65
69 opening_post_id = thread.get_opening_post_id()
66 opening_post_id = thread.get_opening_post_id()
70
67
71 return {
68 return {
72 'post': post,
69 'post': post,
73 'moderator': moderator,
70 'moderator': moderator,
74 'is_opening': is_opening,
71 'is_opening': is_opening,
75 'thread': thread,
72 'thread': thread,
76 'bumpable': can_bump,
73 'bumpable': can_bump,
77 'need_open_link': need_open_link,
74 'need_open_link': need_open_link,
78 'truncated': truncated,
75 'truncated': truncated,
79 'opening_post_id': opening_post_id,
76 'opening_post_id': opening_post_id,
80 }
77 }
81
78
82
79
80 # TODO Fix or remove this method
83 @register.filter(is_safe=True)
81 @register.filter(is_safe=True)
84 def truncate_lines(text, length):
82 def truncate_lines(text, length):
85 if length <= 0:
83 if length <= 0:
86 return ''
84 return ''
87
85
88 html4_singlets = (
86 html4_singlets = (
89 'br', 'col', 'link', 'base', 'img',
87 'br', 'col', 'link', 'base', 'img',
90 'param', 'area', 'hr', 'input'
88 'param', 'area', 'hr', 'input'
91 )
89 )
92
90
93 # Count non-HTML chars/words and keep note of open tags
91 # Count non-HTML chars/words and keep note of open tags
94 pos = 0
92 pos = 0
95 end_text_pos = 0
93 end_text_pos = 0
96 current_len = 0
94 current_len = 0
97 open_tags = []
95 open_tags = []
98
96
99 while current_len <= length:
97 while current_len <= length:
100 m = REGEX_LINES.search(text, pos)
98 m = REGEX_LINES.search(text, pos)
101 if not m:
99 if not m:
102 # Checked through whole string
100 # Checked through whole string
103 break
101 break
104 pos = m.end(0)
102 pos = m.end(0)
105 if m.group(1):
103 if m.group(1):
106 # It's an actual non-HTML word or char
104 # It's an actual non-HTML word or char
107 current_len += 1
105 current_len += 1
108 if current_len == length:
106 if current_len == length:
109 end_text_pos = m.start(0)
107 end_text_pos = m.start(0)
110 continue
108 continue
111 # Check for tag
109 # Check for tag
112 tag = REGEX_TAG.match(m.group(0))
110 tag = REGEX_TAG.match(m.group(0))
113 if not tag or current_len >= length:
111 if not tag or current_len >= length:
114 # Don't worry about non tags or tags after our truncate point
112 # Don't worry about non tags or tags after our truncate point
115 continue
113 continue
116 closing_tag, tagname, self_closing = tag.groups()
114 closing_tag, tagname, self_closing = tag.groups()
117 # Element names are always case-insensitive
115 # Element names are always case-insensitive
118 tagname = tagname.lower()
116 tagname = tagname.lower()
119 if self_closing or tagname in html4_singlets:
117 if self_closing or tagname in html4_singlets:
120 pass
118 pass
121 elif closing_tag:
119 elif closing_tag:
122 # Check for match in open tags list
120 # Check for match in open tags list
123 try:
121 try:
124 i = open_tags.index(tagname)
122 i = open_tags.index(tagname)
125 except ValueError:
123 except ValueError:
126 pass
124 pass
127 else:
125 else:
128 # SGML: An end tag closes, back to the matching start tag,
126 # SGML: An end tag closes, back to the matching start tag,
129 # all unclosed intervening start tags with omitted end tags
127 # all unclosed intervening start tags with omitted end tags
130 open_tags = open_tags[i + 1:]
128 open_tags = open_tags[i + 1:]
131 else:
129 else:
132 # Add it to the start of the open tags list
130 # Add it to the start of the open tags list
133 open_tags.insert(0, tagname)
131 open_tags.insert(0, tagname)
134
132
135 if current_len <= length:
133 if current_len <= length:
136 return text
134 return text
137 out = text[:end_text_pos]
135 out = text[:end_text_pos]
138
136
139 if not out.endswith(ELLIPSIZER):
137 if not out.endswith(ELLIPSIZER):
140 out += ELLIPSIZER
138 out += ELLIPSIZER
141 # Close any tags still open
139 # Close any tags still open
142 for tag in open_tags:
140 for tag in open_tags:
143 out += '</%s>' % tag
141 out += '</%s>' % tag
144 # Return string
142 # Return string
145 return out
143 return out
General Comments 0
You need to be logged in to leave comments. Login now