##// END OF EJS Templates
Removed multitread posts 'feature'
neko259 -
r1704:8410d497 default
parent child Browse files
Show More
@@ -0,0 +1,25 b''
1 # -*- coding: utf-8 -*-
2 # Generated by Django 1.9.5 on 2016-11-27 13:41
3 from __future__ import unicode_literals
4
5 from django.db import migrations, models
6 import django.db.models.deletion
7
8
9 class Migration(migrations.Migration):
10
11 dependencies = [
12 ('boards', '0052_auto_20161120_1344'),
13 ]
14
15 operations = [
16 migrations.RemoveField(
17 model_name='post',
18 name='threads',
19 ),
20 migrations.AlterField(
21 model_name='post',
22 name='thread',
23 field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='boards.Thread'),
24 ),
25 ]
@@ -1,160 +1,160 b''
1 from boards.models.attachment import FILE_TYPES_IMAGE
1 from boards.models.attachment import FILE_TYPES_IMAGE
2 from django.contrib import admin
2 from django.contrib import admin
3 from django.utils.translation import ugettext_lazy as _
3 from django.utils.translation import ugettext_lazy as _
4 from django.core.urlresolvers import reverse
4 from django.core.urlresolvers import reverse
5 from boards.models import Post, Tag, Ban, Thread, Banner, Attachment, KeyPair, GlobalId
5 from boards.models import Post, Tag, Ban, Thread, Banner, Attachment, KeyPair, GlobalId
6
6
7
7
8 @admin.register(Post)
8 @admin.register(Post)
9 class PostAdmin(admin.ModelAdmin):
9 class PostAdmin(admin.ModelAdmin):
10
10
11 list_display = ('id', 'title', 'text', 'poster_ip', 'linked_images',
11 list_display = ('id', 'title', 'text', 'poster_ip', 'linked_images',
12 'foreign', 'tags')
12 'foreign', 'tags')
13 list_filter = ('pub_time',)
13 list_filter = ('pub_time',)
14 search_fields = ('id', 'title', 'text', 'poster_ip')
14 search_fields = ('id', 'title', 'text', 'poster_ip')
15 exclude = ('referenced_posts', 'refmap', 'images', 'global_id')
15 exclude = ('referenced_posts', 'refmap', 'images', 'global_id')
16 readonly_fields = ('poster_ip', 'threads', 'thread', 'linked_images',
16 readonly_fields = ('poster_ip', 'thread', 'linked_images',
17 'attachments', 'uid', 'url', 'pub_time', 'opening', 'linked_global_id',
17 'attachments', 'uid', 'url', 'pub_time', 'opening', 'linked_global_id',
18 'version', 'foreign', 'tags')
18 'version', 'foreign', 'tags')
19
19
20 def ban_poster(self, request, queryset):
20 def ban_poster(self, request, queryset):
21 bans = 0
21 bans = 0
22 for post in queryset:
22 for post in queryset:
23 poster_ip = post.poster_ip
23 poster_ip = post.poster_ip
24 ban, created = Ban.objects.get_or_create(ip=poster_ip)
24 ban, created = Ban.objects.get_or_create(ip=poster_ip)
25 if created:
25 if created:
26 bans += 1
26 bans += 1
27 self.message_user(request, _('{} posters were banned').format(bans))
27 self.message_user(request, _('{} posters were banned').format(bans))
28
28
29 def ban_latter_with_delete(self, request, queryset):
29 def ban_latter_with_delete(self, request, queryset):
30 bans = 0
30 bans = 0
31 hidden = 0
31 hidden = 0
32 for post in queryset:
32 for post in queryset:
33 poster_ip = post.poster_ip
33 poster_ip = post.poster_ip
34 ban, created = Ban.objects.get_or_create(ip=poster_ip)
34 ban, created = Ban.objects.get_or_create(ip=poster_ip)
35 if created:
35 if created:
36 bans += 1
36 bans += 1
37 posts = Post.objects.filter(poster_ip=poster_ip, id__gte=post.id)
37 posts = Post.objects.filter(poster_ip=poster_ip, id__gte=post.id)
38 hidden += posts.count()
38 hidden += posts.count()
39 posts.delete()
39 posts.delete()
40 self.message_user(request, _('{} posters were banned, {} messages were removed.').format(bans, hidden))
40 self.message_user(request, _('{} posters were banned, {} messages were removed.').format(bans, hidden))
41 ban_latter_with_delete.short_description = _('Ban user and delete posts starting from this one and later')
41 ban_latter_with_delete.short_description = _('Ban user and delete posts starting from this one and later')
42
42
43 def linked_images(self, obj: Post):
43 def linked_images(self, obj: Post):
44 images = obj.attachments.filter(mimetype__in=FILE_TYPES_IMAGE)
44 images = obj.attachments.filter(mimetype__in=FILE_TYPES_IMAGE)
45 image_urls = ['<a href="{}"><img src="{}" /></a>'.format(
45 image_urls = ['<a href="{}"><img src="{}" /></a>'.format(
46 reverse('admin:%s_%s_change' % (image._meta.app_label,
46 reverse('admin:%s_%s_change' % (image._meta.app_label,
47 image._meta.model_name),
47 image._meta.model_name),
48 args=[image.id]), image.get_thumb_url()) for image in images]
48 args=[image.id]), image.get_thumb_url()) for image in images]
49 return ', '.join(image_urls)
49 return ', '.join(image_urls)
50 linked_images.allow_tags = True
50 linked_images.allow_tags = True
51
51
52 def linked_global_id(self, obj: Post):
52 def linked_global_id(self, obj: Post):
53 global_id = obj.global_id
53 global_id = obj.global_id
54 if global_id is not None:
54 if global_id is not None:
55 return '<a href="{}">{}</a>'.format(
55 return '<a href="{}">{}</a>'.format(
56 reverse('admin:%s_%s_change' % (global_id._meta.app_label,
56 reverse('admin:%s_%s_change' % (global_id._meta.app_label,
57 global_id._meta.model_name),
57 global_id._meta.model_name),
58 args=[global_id.id]), str(global_id))
58 args=[global_id.id]), str(global_id))
59 linked_global_id.allow_tags = True
59 linked_global_id.allow_tags = True
60
60
61 def tags(self, obj: Post):
61 def tags(self, obj: Post):
62 return ', '.join([tag.name for tag in obj.get_tags()])
62 return ', '.join([tag.name for tag in obj.get_tags()])
63
63
64 def save_model(self, request, obj, form, change):
64 def save_model(self, request, obj, form, change):
65 obj.increment_version()
65 obj.increment_version()
66 obj.save()
66 obj.save()
67 obj.clear_cache()
67 obj.clear_cache()
68
68
69 def foreign(self, obj: Post):
69 def foreign(self, obj: Post):
70 return obj is not None and obj.global_id is not None and\
70 return obj is not None and obj.global_id is not None and\
71 not obj.global_id.is_local()
71 not obj.global_id.is_local()
72
72
73 actions = ['ban_poster', 'ban_latter_with_delete']
73 actions = ['ban_poster', 'ban_latter_with_delete']
74
74
75
75
76 @admin.register(Tag)
76 @admin.register(Tag)
77 class TagAdmin(admin.ModelAdmin):
77 class TagAdmin(admin.ModelAdmin):
78
78
79 def thread_count(self, obj: Tag) -> int:
79 def thread_count(self, obj: Tag) -> int:
80 return obj.get_thread_count()
80 return obj.get_thread_count()
81
81
82 def display_children(self, obj: Tag):
82 def display_children(self, obj: Tag):
83 return ', '.join([str(child) for child in obj.get_children().all()])
83 return ', '.join([str(child) for child in obj.get_children().all()])
84
84
85 def save_model(self, request, obj, form, change):
85 def save_model(self, request, obj, form, change):
86 super().save_model(request, obj, form, change)
86 super().save_model(request, obj, form, change)
87 for thread in obj.get_threads().all():
87 for thread in obj.get_threads().all():
88 thread.refresh_tags()
88 thread.refresh_tags()
89 list_display = ('name', 'thread_count', 'display_children')
89 list_display = ('name', 'thread_count', 'display_children')
90 search_fields = ('name',)
90 search_fields = ('name',)
91
91
92
92
93 @admin.register(Thread)
93 @admin.register(Thread)
94 class ThreadAdmin(admin.ModelAdmin):
94 class ThreadAdmin(admin.ModelAdmin):
95
95
96 def title(self, obj: Thread) -> str:
96 def title(self, obj: Thread) -> str:
97 return obj.get_opening_post().get_title()
97 return obj.get_opening_post().get_title()
98
98
99 def reply_count(self, obj: Thread) -> int:
99 def reply_count(self, obj: Thread) -> int:
100 return obj.get_reply_count()
100 return obj.get_reply_count()
101
101
102 def ip(self, obj: Thread):
102 def ip(self, obj: Thread):
103 return obj.get_opening_post().poster_ip
103 return obj.get_opening_post().poster_ip
104
104
105 def display_tags(self, obj: Thread):
105 def display_tags(self, obj: Thread):
106 return ', '.join([str(tag) for tag in obj.get_tags().all()])
106 return ', '.join([str(tag) for tag in obj.get_tags().all()])
107
107
108 def op(self, obj: Thread):
108 def op(self, obj: Thread):
109 return obj.get_opening_post_id()
109 return obj.get_opening_post_id()
110
110
111 # Save parent tags when editing tags
111 # Save parent tags when editing tags
112 def save_related(self, request, form, formsets, change):
112 def save_related(self, request, form, formsets, change):
113 super().save_related(request, form, formsets, change)
113 super().save_related(request, form, formsets, change)
114 form.instance.refresh_tags()
114 form.instance.refresh_tags()
115
115
116 def save_model(self, request, obj, form, change):
116 def save_model(self, request, obj, form, change):
117 op = obj.get_opening_post()
117 op = obj.get_opening_post()
118 op.increment_version()
118 op.increment_version()
119 op.save(update_fields=['version'])
119 op.save(update_fields=['version'])
120 obj.save()
120 obj.save()
121 op.clear_cache()
121 op.clear_cache()
122
122
123 list_display = ('id', 'op', 'title', 'reply_count', 'status', 'ip',
123 list_display = ('id', 'op', 'title', 'reply_count', 'status', 'ip',
124 'display_tags')
124 'display_tags')
125 list_filter = ('bump_time', 'status')
125 list_filter = ('bump_time', 'status')
126 search_fields = ('id', 'title')
126 search_fields = ('id', 'title')
127 filter_horizontal = ('tags',)
127 filter_horizontal = ('tags',)
128
128
129
129
130 @admin.register(KeyPair)
130 @admin.register(KeyPair)
131 class KeyPairAdmin(admin.ModelAdmin):
131 class KeyPairAdmin(admin.ModelAdmin):
132 list_display = ('public_key', 'primary')
132 list_display = ('public_key', 'primary')
133 list_filter = ('primary',)
133 list_filter = ('primary',)
134 search_fields = ('public_key',)
134 search_fields = ('public_key',)
135
135
136
136
137 @admin.register(Ban)
137 @admin.register(Ban)
138 class BanAdmin(admin.ModelAdmin):
138 class BanAdmin(admin.ModelAdmin):
139 list_display = ('ip', 'can_read')
139 list_display = ('ip', 'can_read')
140 list_filter = ('can_read',)
140 list_filter = ('can_read',)
141 search_fields = ('ip',)
141 search_fields = ('ip',)
142
142
143
143
144 @admin.register(Banner)
144 @admin.register(Banner)
145 class BannerAdmin(admin.ModelAdmin):
145 class BannerAdmin(admin.ModelAdmin):
146 list_display = ('title', 'text')
146 list_display = ('title', 'text')
147
147
148
148
149 @admin.register(Attachment)
149 @admin.register(Attachment)
150 class AttachmentAdmin(admin.ModelAdmin):
150 class AttachmentAdmin(admin.ModelAdmin):
151 search_fields = ('alias',)
151 search_fields = ('alias',)
152
152
153
153
154 @admin.register(GlobalId)
154 @admin.register(GlobalId)
155 class GlobalIdAdmin(admin.ModelAdmin):
155 class GlobalIdAdmin(admin.ModelAdmin):
156 def is_linked(self, obj):
156 def is_linked(self, obj):
157 return Post.objects.filter(global_id=obj).exists()
157 return Post.objects.filter(global_id=obj).exists()
158
158
159 list_display = ('__str__', 'is_linked',)
159 list_display = ('__str__', 'is_linked',)
160 readonly_fields = ('content',)
160 readonly_fields = ('content',)
@@ -1,500 +1,478 b''
1 import hashlib
1 import hashlib
2 import re
2 import re
3 import time
3 import time
4 import logging
4 import logging
5
5
6 import pytz
6 import pytz
7
7
8 from django import forms
8 from django import forms
9 from django.core.files.uploadedfile import SimpleUploadedFile
9 from django.core.files.uploadedfile import SimpleUploadedFile
10 from django.core.exceptions import ObjectDoesNotExist
10 from django.core.exceptions import ObjectDoesNotExist
11 from django.forms.utils import ErrorList
11 from django.forms.utils import ErrorList
12 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
12 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
13 from django.utils import timezone
13 from django.utils import timezone
14
14
15 from boards.abstracts.settingsmanager import get_settings_manager
15 from boards.abstracts.settingsmanager import get_settings_manager
16 from boards.abstracts.attachment_alias import get_image_by_alias
16 from boards.abstracts.attachment_alias import get_image_by_alias
17 from boards.mdx_neboard import formatters
17 from boards.mdx_neboard import formatters
18 from boards.models.attachment.downloaders import download
18 from boards.models.attachment.downloaders import download
19 from boards.models.post import TITLE_MAX_LENGTH
19 from boards.models.post import TITLE_MAX_LENGTH
20 from boards.models import Tag, Post
20 from boards.models import Tag, Post
21 from boards.utils import validate_file_size, get_file_mimetype, \
21 from boards.utils import validate_file_size, get_file_mimetype, \
22 FILE_EXTENSION_DELIMITER
22 FILE_EXTENSION_DELIMITER
23 from neboard import settings
23 from neboard import settings
24 import boards.settings as board_settings
24 import boards.settings as board_settings
25 import neboard
25 import neboard
26
26
27 POW_HASH_LENGTH = 16
27 POW_HASH_LENGTH = 16
28 POW_LIFE_MINUTES = 5
28 POW_LIFE_MINUTES = 5
29
29
30 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
30 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
31 REGEX_USERNAMES = re.compile(r'^[\w\s\d,]+$', re.UNICODE)
31 REGEX_USERNAMES = re.compile(r'^[\w\s\d,]+$', re.UNICODE)
32 REGEX_URL = re.compile(r'^(http|https|ftp|magnet):\/\/', re.UNICODE)
32 REGEX_URL = re.compile(r'^(http|https|ftp|magnet):\/\/', re.UNICODE)
33
33
34 VETERAN_POSTING_DELAY = 5
34 VETERAN_POSTING_DELAY = 5
35
35
36 ATTRIBUTE_PLACEHOLDER = 'placeholder'
36 ATTRIBUTE_PLACEHOLDER = 'placeholder'
37 ATTRIBUTE_ROWS = 'rows'
37 ATTRIBUTE_ROWS = 'rows'
38
38
39 LAST_POST_TIME = 'last_post_time'
39 LAST_POST_TIME = 'last_post_time'
40 LAST_LOGIN_TIME = 'last_login_time'
40 LAST_LOGIN_TIME = 'last_login_time'
41 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
41 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
42 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
42 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
43
43
44 LABEL_TITLE = _('Title')
44 LABEL_TITLE = _('Title')
45 LABEL_TEXT = _('Text')
45 LABEL_TEXT = _('Text')
46 LABEL_TAG = _('Tag')
46 LABEL_TAG = _('Tag')
47 LABEL_SEARCH = _('Search')
47 LABEL_SEARCH = _('Search')
48
48
49 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
49 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
50 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
50 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
51
51
52 TAG_MAX_LENGTH = 20
52 TAG_MAX_LENGTH = 20
53
53
54 TEXTAREA_ROWS = 4
54 TEXTAREA_ROWS = 4
55
55
56 TRIPCODE_DELIM = '#'
56 TRIPCODE_DELIM = '#'
57
57
58 # TODO Maybe this may be converted into the database table?
58 # TODO Maybe this may be converted into the database table?
59 MIMETYPE_EXTENSIONS = {
59 MIMETYPE_EXTENSIONS = {
60 'image/jpeg': 'jpeg',
60 'image/jpeg': 'jpeg',
61 'image/png': 'png',
61 'image/png': 'png',
62 'image/gif': 'gif',
62 'image/gif': 'gif',
63 'video/webm': 'webm',
63 'video/webm': 'webm',
64 'application/pdf': 'pdf',
64 'application/pdf': 'pdf',
65 'x-diff': 'diff',
65 'x-diff': 'diff',
66 'image/svg+xml': 'svg',
66 'image/svg+xml': 'svg',
67 'application/x-shockwave-flash': 'swf',
67 'application/x-shockwave-flash': 'swf',
68 'image/x-ms-bmp': 'bmp',
68 'image/x-ms-bmp': 'bmp',
69 'image/bmp': 'bmp',
69 'image/bmp': 'bmp',
70 }
70 }
71
71
72
72
73 logger = logging.getLogger('boards.forms')
73 logger = logging.getLogger('boards.forms')
74
74
75
75
76 def get_timezones():
76 def get_timezones():
77 timezones = []
77 timezones = []
78 for tz in pytz.common_timezones:
78 for tz in pytz.common_timezones:
79 timezones.append((tz, tz),)
79 timezones.append((tz, tz),)
80 return timezones
80 return timezones
81
81
82
82
83 class FormatPanel(forms.Textarea):
83 class FormatPanel(forms.Textarea):
84 """
84 """
85 Panel for text formatting. Consists of buttons to add different tags to the
85 Panel for text formatting. Consists of buttons to add different tags to the
86 form text area.
86 form text area.
87 """
87 """
88
88
89 def render(self, name, value, attrs=None):
89 def render(self, name, value, attrs=None):
90 output = '<div id="mark-panel">'
90 output = '<div id="mark-panel">'
91 for formatter in formatters:
91 for formatter in formatters:
92 output += '<span class="mark_btn"' + \
92 output += '<span class="mark_btn"' + \
93 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
93 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
94 '\', \'' + formatter.format_right + '\')">' + \
94 '\', \'' + formatter.format_right + '\')">' + \
95 formatter.preview_left + formatter.name + \
95 formatter.preview_left + formatter.name + \
96 formatter.preview_right + '</span>'
96 formatter.preview_right + '</span>'
97
97
98 output += '</div>'
98 output += '</div>'
99 output += super(FormatPanel, self).render(name, value, attrs=attrs)
99 output += super(FormatPanel, self).render(name, value, attrs=attrs)
100
100
101 return output
101 return output
102
102
103
103
104 class PlainErrorList(ErrorList):
104 class PlainErrorList(ErrorList):
105 def __unicode__(self):
105 def __unicode__(self):
106 return self.as_text()
106 return self.as_text()
107
107
108 def as_text(self):
108 def as_text(self):
109 return ''.join(['(!) %s ' % e for e in self])
109 return ''.join(['(!) %s ' % e for e in self])
110
110
111
111
112 class NeboardForm(forms.Form):
112 class NeboardForm(forms.Form):
113 """
113 """
114 Form with neboard-specific formatting.
114 Form with neboard-specific formatting.
115 """
115 """
116 required_css_class = 'required-field'
116 required_css_class = 'required-field'
117
117
118 def as_div(self):
118 def as_div(self):
119 """
119 """
120 Returns this form rendered as HTML <as_div>s.
120 Returns this form rendered as HTML <as_div>s.
121 """
121 """
122
122
123 return self._html_output(
123 return self._html_output(
124 # TODO Do not show hidden rows in the list here
124 # TODO Do not show hidden rows in the list here
125 normal_row='<div class="form-row">'
125 normal_row='<div class="form-row">'
126 '<div class="form-label">'
126 '<div class="form-label">'
127 '%(label)s'
127 '%(label)s'
128 '</div>'
128 '</div>'
129 '<div class="form-input">'
129 '<div class="form-input">'
130 '%(field)s'
130 '%(field)s'
131 '</div>'
131 '</div>'
132 '</div>'
132 '</div>'
133 '<div class="form-row">'
133 '<div class="form-row">'
134 '%(help_text)s'
134 '%(help_text)s'
135 '</div>',
135 '</div>',
136 error_row='<div class="form-row">'
136 error_row='<div class="form-row">'
137 '<div class="form-label"></div>'
137 '<div class="form-label"></div>'
138 '<div class="form-errors">%s</div>'
138 '<div class="form-errors">%s</div>'
139 '</div>',
139 '</div>',
140 row_ender='</div>',
140 row_ender='</div>',
141 help_text_html='%s',
141 help_text_html='%s',
142 errors_on_separate_row=True)
142 errors_on_separate_row=True)
143
143
144 def as_json_errors(self):
144 def as_json_errors(self):
145 errors = []
145 errors = []
146
146
147 for name, field in list(self.fields.items()):
147 for name, field in list(self.fields.items()):
148 if self[name].errors:
148 if self[name].errors:
149 errors.append({
149 errors.append({
150 'field': name,
150 'field': name,
151 'errors': self[name].errors.as_text(),
151 'errors': self[name].errors.as_text(),
152 })
152 })
153
153
154 return errors
154 return errors
155
155
156
156
157 class PostForm(NeboardForm):
157 class PostForm(NeboardForm):
158
158
159 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
159 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
160 label=LABEL_TITLE,
160 label=LABEL_TITLE,
161 widget=forms.TextInput(
161 widget=forms.TextInput(
162 attrs={ATTRIBUTE_PLACEHOLDER:
162 attrs={ATTRIBUTE_PLACEHOLDER:
163 'test#tripcode'}))
163 'test#tripcode'}))
164 text = forms.CharField(
164 text = forms.CharField(
165 widget=FormatPanel(attrs={
165 widget=FormatPanel(attrs={
166 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
166 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
167 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
167 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
168 }),
168 }),
169 required=False, label=LABEL_TEXT)
169 required=False, label=LABEL_TEXT)
170 file = forms.FileField(required=False, label=_('File'),
170 file = forms.FileField(required=False, label=_('File'),
171 widget=forms.ClearableFileInput(
171 widget=forms.ClearableFileInput(
172 attrs={'accept': 'file/*'}))
172 attrs={'accept': 'file/*'}))
173 file_url = forms.CharField(required=False, label=_('File URL'),
173 file_url = forms.CharField(required=False, label=_('File URL'),
174 widget=forms.TextInput(
174 widget=forms.TextInput(
175 attrs={ATTRIBUTE_PLACEHOLDER:
175 attrs={ATTRIBUTE_PLACEHOLDER:
176 'http://example.com/image.png'}))
176 'http://example.com/image.png'}))
177
177
178 # This field is for spam prevention only
178 # This field is for spam prevention only
179 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
179 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
180 widget=forms.TextInput(attrs={
180 widget=forms.TextInput(attrs={
181 'class': 'form-email'}))
181 'class': 'form-email'}))
182 threads = forms.CharField(required=False, label=_('Additional threads'),
183 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
184 '123 456 789'}))
185 subscribe = forms.BooleanField(required=False, label=_('Subscribe to thread'))
182 subscribe = forms.BooleanField(required=False, label=_('Subscribe to thread'))
186
183
187 guess = forms.CharField(widget=forms.HiddenInput(), required=False)
184 guess = forms.CharField(widget=forms.HiddenInput(), required=False)
188 timestamp = forms.CharField(widget=forms.HiddenInput(), required=False)
185 timestamp = forms.CharField(widget=forms.HiddenInput(), required=False)
189 iteration = forms.CharField(widget=forms.HiddenInput(), required=False)
186 iteration = forms.CharField(widget=forms.HiddenInput(), required=False)
190
187
191 session = None
188 session = None
192 need_to_ban = False
189 need_to_ban = False
193 image = None
190 image = None
194
191
195 def _update_file_extension(self, file):
192 def _update_file_extension(self, file):
196 if file:
193 if file:
197 mimetype = get_file_mimetype(file)
194 mimetype = get_file_mimetype(file)
198 extension = MIMETYPE_EXTENSIONS.get(mimetype)
195 extension = MIMETYPE_EXTENSIONS.get(mimetype)
199 if extension:
196 if extension:
200 filename = file.name.split(FILE_EXTENSION_DELIMITER, 1)[0]
197 filename = file.name.split(FILE_EXTENSION_DELIMITER, 1)[0]
201 new_filename = filename + FILE_EXTENSION_DELIMITER + extension
198 new_filename = filename + FILE_EXTENSION_DELIMITER + extension
202
199
203 file.name = new_filename
200 file.name = new_filename
204 else:
201 else:
205 logger = logging.getLogger('boards.forms.extension')
202 logger = logging.getLogger('boards.forms.extension')
206
203
207 logger.info('Unrecognized file mimetype: {}'.format(mimetype))
204 logger.info('Unrecognized file mimetype: {}'.format(mimetype))
208
205
209 def clean_title(self):
206 def clean_title(self):
210 title = self.cleaned_data['title']
207 title = self.cleaned_data['title']
211 if title:
208 if title:
212 if len(title) > TITLE_MAX_LENGTH:
209 if len(title) > TITLE_MAX_LENGTH:
213 raise forms.ValidationError(_('Title must have less than %s '
210 raise forms.ValidationError(_('Title must have less than %s '
214 'characters') %
211 'characters') %
215 str(TITLE_MAX_LENGTH))
212 str(TITLE_MAX_LENGTH))
216 return title
213 return title
217
214
218 def clean_text(self):
215 def clean_text(self):
219 text = self.cleaned_data['text'].strip()
216 text = self.cleaned_data['text'].strip()
220 if text:
217 if text:
221 max_length = board_settings.get_int('Forms', 'MaxTextLength')
218 max_length = board_settings.get_int('Forms', 'MaxTextLength')
222 if len(text) > max_length:
219 if len(text) > max_length:
223 raise forms.ValidationError(_('Text must have less than %s '
220 raise forms.ValidationError(_('Text must have less than %s '
224 'characters') % str(max_length))
221 'characters') % str(max_length))
225 return text
222 return text
226
223
227 def clean_file(self):
224 def clean_file(self):
228 file = self.cleaned_data['file']
225 file = self.cleaned_data['file']
229
226
230 if file:
227 if file:
231 validate_file_size(file.size)
228 validate_file_size(file.size)
232 self._update_file_extension(file)
229 self._update_file_extension(file)
233
230
234 return file
231 return file
235
232
236 def clean_file_url(self):
233 def clean_file_url(self):
237 url = self.cleaned_data['file_url']
234 url = self.cleaned_data['file_url']
238
235
239 file = None
236 file = None
240
237
241 if url:
238 if url:
242 try:
239 try:
243 file = get_image_by_alias(url, self.session)
240 file = get_image_by_alias(url, self.session)
244 self.image = file
241 self.image = file
245
242
246 if file is not None:
243 if file is not None:
247 return
244 return
248
245
249 if file is None:
246 if file is None:
250 file = self._get_file_from_url(url)
247 file = self._get_file_from_url(url)
251 if not file:
248 if not file:
252 raise forms.ValidationError(_('Invalid URL'))
249 raise forms.ValidationError(_('Invalid URL'))
253 else:
250 else:
254 validate_file_size(file.size)
251 validate_file_size(file.size)
255 self._update_file_extension(file)
252 self._update_file_extension(file)
256 except forms.ValidationError as e:
253 except forms.ValidationError as e:
257 # Assume we will get the plain URL instead of a file and save it
254 # Assume we will get the plain URL instead of a file and save it
258 if REGEX_URL.match(url):
255 if REGEX_URL.match(url):
259 logger.info('Error in forms: {}'.format(e))
256 logger.info('Error in forms: {}'.format(e))
260 return url
257 return url
261 else:
258 else:
262 raise e
259 raise e
263
260
264 return file
261 return file
265
262
266 def clean_threads(self):
267 threads_str = self.cleaned_data['threads']
268
269 if len(threads_str) > 0:
270 threads_id_list = threads_str.split(' ')
271
272 threads = list()
273
274 for thread_id in threads_id_list:
275 try:
276 thread = Post.objects.get(id=int(thread_id))
277 if not thread.is_opening() or thread.get_thread().is_archived():
278 raise ObjectDoesNotExist()
279 threads.append(thread)
280 except (ObjectDoesNotExist, ValueError):
281 raise forms.ValidationError(_('Invalid additional thread list'))
282
283 return threads
284
285 def clean(self):
263 def clean(self):
286 cleaned_data = super(PostForm, self).clean()
264 cleaned_data = super(PostForm, self).clean()
287
265
288 if cleaned_data['email']:
266 if cleaned_data['email']:
289 if board_settings.get_bool('Forms', 'Autoban'):
267 if board_settings.get_bool('Forms', 'Autoban'):
290 self.need_to_ban = True
268 self.need_to_ban = True
291 raise forms.ValidationError('A human cannot enter a hidden field')
269 raise forms.ValidationError('A human cannot enter a hidden field')
292
270
293 if not self.errors:
271 if not self.errors:
294 self._clean_text_file()
272 self._clean_text_file()
295
273
296 limit_speed = board_settings.get_bool('Forms', 'LimitPostingSpeed')
274 limit_speed = board_settings.get_bool('Forms', 'LimitPostingSpeed')
297 limit_first = board_settings.get_bool('Forms', 'LimitFirstPosting')
275 limit_first = board_settings.get_bool('Forms', 'LimitFirstPosting')
298
276
299 settings_manager = get_settings_manager(self)
277 settings_manager = get_settings_manager(self)
300 if not self.errors and limit_speed or (limit_first and not settings_manager.get_setting('confirmed_user')):
278 if not self.errors and limit_speed or (limit_first and not settings_manager.get_setting('confirmed_user')):
301 pow_difficulty = board_settings.get_int('Forms', 'PowDifficulty')
279 pow_difficulty = board_settings.get_int('Forms', 'PowDifficulty')
302 if pow_difficulty > 0:
280 if pow_difficulty > 0:
303 # PoW-based
281 # PoW-based
304 if cleaned_data['timestamp'] \
282 if cleaned_data['timestamp'] \
305 and cleaned_data['iteration'] and cleaned_data['guess'] \
283 and cleaned_data['iteration'] and cleaned_data['guess'] \
306 and not settings_manager.get_setting('confirmed_user'):
284 and not settings_manager.get_setting('confirmed_user'):
307 self._validate_hash(cleaned_data['timestamp'], cleaned_data['iteration'], cleaned_data['guess'], cleaned_data['text'])
285 self._validate_hash(cleaned_data['timestamp'], cleaned_data['iteration'], cleaned_data['guess'], cleaned_data['text'])
308 else:
286 else:
309 # Time-based
287 # Time-based
310 self._validate_posting_speed()
288 self._validate_posting_speed()
311 settings_manager.set_setting('confirmed_user', True)
289 settings_manager.set_setting('confirmed_user', True)
312
290
313 return cleaned_data
291 return cleaned_data
314
292
315 def get_file(self):
293 def get_file(self):
316 """
294 """
317 Gets file from form or URL.
295 Gets file from form or URL.
318 """
296 """
319
297
320 file = self.cleaned_data['file']
298 file = self.cleaned_data['file']
321 if type(self.cleaned_data['file_url']) is not str:
299 if type(self.cleaned_data['file_url']) is not str:
322 file_url = self.cleaned_data['file_url']
300 file_url = self.cleaned_data['file_url']
323 else:
301 else:
324 file_url = None
302 file_url = None
325 return file or file_url
303 return file or file_url
326
304
327 def get_file_url(self):
305 def get_file_url(self):
328 if not self.get_file():
306 if not self.get_file():
329 return self.cleaned_data['file_url']
307 return self.cleaned_data['file_url']
330
308
331 def get_tripcode(self):
309 def get_tripcode(self):
332 title = self.cleaned_data['title']
310 title = self.cleaned_data['title']
333 if title is not None and TRIPCODE_DELIM in title:
311 if title is not None and TRIPCODE_DELIM in title:
334 code = title.split(TRIPCODE_DELIM, maxsplit=1)[1] + neboard.settings.SECRET_KEY
312 code = title.split(TRIPCODE_DELIM, maxsplit=1)[1] + neboard.settings.SECRET_KEY
335 tripcode = hashlib.md5(code.encode()).hexdigest()
313 tripcode = hashlib.md5(code.encode()).hexdigest()
336 else:
314 else:
337 tripcode = ''
315 tripcode = ''
338 return tripcode
316 return tripcode
339
317
340 def get_title(self):
318 def get_title(self):
341 title = self.cleaned_data['title']
319 title = self.cleaned_data['title']
342 if title is not None and TRIPCODE_DELIM in title:
320 if title is not None and TRIPCODE_DELIM in title:
343 return title.split(TRIPCODE_DELIM, maxsplit=1)[0]
321 return title.split(TRIPCODE_DELIM, maxsplit=1)[0]
344 else:
322 else:
345 return title
323 return title
346
324
347 def get_images(self):
325 def get_images(self):
348 if self.image:
326 if self.image:
349 return [self.image]
327 return [self.image]
350 else:
328 else:
351 return []
329 return []
352
330
353 def is_subscribe(self):
331 def is_subscribe(self):
354 return self.cleaned_data['subscribe']
332 return self.cleaned_data['subscribe']
355
333
356 def _clean_text_file(self):
334 def _clean_text_file(self):
357 text = self.cleaned_data.get('text')
335 text = self.cleaned_data.get('text')
358 file = self.get_file()
336 file = self.get_file()
359 file_url = self.get_file_url()
337 file_url = self.get_file_url()
360 images = self.get_images()
338 images = self.get_images()
361
339
362 if (not text) and (not file) and (not file_url) and len(images) == 0:
340 if (not text) and (not file) and (not file_url) and len(images) == 0:
363 error_message = _('Either text or file must be entered.')
341 error_message = _('Either text or file must be entered.')
364 self._errors['text'] = self.error_class([error_message])
342 self._errors['text'] = self.error_class([error_message])
365
343
366 def _validate_posting_speed(self):
344 def _validate_posting_speed(self):
367 can_post = True
345 can_post = True
368
346
369 posting_delay = board_settings.get_int('Forms', 'PostingDelay')
347 posting_delay = board_settings.get_int('Forms', 'PostingDelay')
370
348
371 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
349 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
372 now = time.time()
350 now = time.time()
373
351
374 current_delay = 0
352 current_delay = 0
375
353
376 if LAST_POST_TIME not in self.session:
354 if LAST_POST_TIME not in self.session:
377 self.session[LAST_POST_TIME] = now
355 self.session[LAST_POST_TIME] = now
378
356
379 need_delay = True
357 need_delay = True
380 else:
358 else:
381 last_post_time = self.session.get(LAST_POST_TIME)
359 last_post_time = self.session.get(LAST_POST_TIME)
382 current_delay = int(now - last_post_time)
360 current_delay = int(now - last_post_time)
383
361
384 need_delay = current_delay < posting_delay
362 need_delay = current_delay < posting_delay
385
363
386 if need_delay:
364 if need_delay:
387 delay = posting_delay - current_delay
365 delay = posting_delay - current_delay
388 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
366 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
389 delay) % {'delay': delay}
367 delay) % {'delay': delay}
390 self._errors['text'] = self.error_class([error_message])
368 self._errors['text'] = self.error_class([error_message])
391
369
392 can_post = False
370 can_post = False
393
371
394 if can_post:
372 if can_post:
395 self.session[LAST_POST_TIME] = now
373 self.session[LAST_POST_TIME] = now
396
374
397 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
375 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
398 """
376 """
399 Gets an file file from URL.
377 Gets an file file from URL.
400 """
378 """
401
379
402 try:
380 try:
403 return download(url)
381 return download(url)
404 except forms.ValidationError as e:
382 except forms.ValidationError as e:
405 raise e
383 raise e
406 except Exception as e:
384 except Exception as e:
407 raise forms.ValidationError(e)
385 raise forms.ValidationError(e)
408
386
409 def _validate_hash(self, timestamp: str, iteration: str, guess: str, message: str):
387 def _validate_hash(self, timestamp: str, iteration: str, guess: str, message: str):
410 post_time = timezone.datetime.fromtimestamp(
388 post_time = timezone.datetime.fromtimestamp(
411 int(timestamp[:-3]), tz=timezone.get_current_timezone())
389 int(timestamp[:-3]), tz=timezone.get_current_timezone())
412
390
413 payload = timestamp + message.replace('\r\n', '\n')
391 payload = timestamp + message.replace('\r\n', '\n')
414 difficulty = board_settings.get_int('Forms', 'PowDifficulty')
392 difficulty = board_settings.get_int('Forms', 'PowDifficulty')
415 target = str(int(2 ** (POW_HASH_LENGTH * 3) / difficulty))
393 target = str(int(2 ** (POW_HASH_LENGTH * 3) / difficulty))
416 if len(target) < POW_HASH_LENGTH:
394 if len(target) < POW_HASH_LENGTH:
417 target = '0' * (POW_HASH_LENGTH - len(target)) + target
395 target = '0' * (POW_HASH_LENGTH - len(target)) + target
418
396
419 computed_guess = hashlib.sha256((payload + iteration).encode())\
397 computed_guess = hashlib.sha256((payload + iteration).encode())\
420 .hexdigest()[0:POW_HASH_LENGTH]
398 .hexdigest()[0:POW_HASH_LENGTH]
421 if guess != computed_guess or guess > target:
399 if guess != computed_guess or guess > target:
422 self._errors['text'] = self.error_class(
400 self._errors['text'] = self.error_class(
423 [_('Invalid PoW.')])
401 [_('Invalid PoW.')])
424
402
425
403
426
404
427 class ThreadForm(PostForm):
405 class ThreadForm(PostForm):
428
406
429 tags = forms.CharField(
407 tags = forms.CharField(
430 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
408 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
431 max_length=100, label=_('Tags'), required=True)
409 max_length=100, label=_('Tags'), required=True)
432 monochrome = forms.BooleanField(label=_('Monochrome'), required=False)
410 monochrome = forms.BooleanField(label=_('Monochrome'), required=False)
433
411
434 def clean_tags(self):
412 def clean_tags(self):
435 tags = self.cleaned_data['tags'].strip()
413 tags = self.cleaned_data['tags'].strip()
436
414
437 if not tags or not REGEX_TAGS.match(tags):
415 if not tags or not REGEX_TAGS.match(tags):
438 raise forms.ValidationError(
416 raise forms.ValidationError(
439 _('Inappropriate characters in tags.'))
417 _('Inappropriate characters in tags.'))
440
418
441 default_tag_name = board_settings.get('Forms', 'DefaultTag')\
419 default_tag_name = board_settings.get('Forms', 'DefaultTag')\
442 .strip().lower()
420 .strip().lower()
443
421
444 required_tag_exists = False
422 required_tag_exists = False
445 tag_set = set()
423 tag_set = set()
446 for tag_string in tags.split():
424 for tag_string in tags.split():
447 if tag_string.strip().lower() == default_tag_name:
425 if tag_string.strip().lower() == default_tag_name:
448 required_tag_exists = True
426 required_tag_exists = True
449 tag, created = Tag.objects.get_or_create(
427 tag, created = Tag.objects.get_or_create(
450 name=tag_string.strip().lower(), required=True)
428 name=tag_string.strip().lower(), required=True)
451 else:
429 else:
452 tag, created = Tag.objects.get_or_create(
430 tag, created = Tag.objects.get_or_create(
453 name=tag_string.strip().lower())
431 name=tag_string.strip().lower())
454 tag_set.add(tag)
432 tag_set.add(tag)
455
433
456 # If this is a new tag, don't check for its parents because nobody
434 # If this is a new tag, don't check for its parents because nobody
457 # added them yet
435 # added them yet
458 if not created:
436 if not created:
459 tag_set |= set(tag.get_all_parents())
437 tag_set |= set(tag.get_all_parents())
460
438
461 for tag in tag_set:
439 for tag in tag_set:
462 if tag.required:
440 if tag.required:
463 required_tag_exists = True
441 required_tag_exists = True
464 break
442 break
465
443
466 # Use default tag if no section exists
444 # Use default tag if no section exists
467 if not required_tag_exists:
445 if not required_tag_exists:
468 default_tag, created = Tag.objects.get_or_create(
446 default_tag, created = Tag.objects.get_or_create(
469 name=default_tag_name, required=True)
447 name=default_tag_name, required=True)
470 tag_set.add(default_tag)
448 tag_set.add(default_tag)
471
449
472 return tag_set
450 return tag_set
473
451
474 def clean(self):
452 def clean(self):
475 cleaned_data = super(ThreadForm, self).clean()
453 cleaned_data = super(ThreadForm, self).clean()
476
454
477 return cleaned_data
455 return cleaned_data
478
456
479 def is_monochrome(self):
457 def is_monochrome(self):
480 return self.cleaned_data['monochrome']
458 return self.cleaned_data['monochrome']
481
459
482
460
483 class SettingsForm(NeboardForm):
461 class SettingsForm(NeboardForm):
484
462
485 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
463 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
486 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
464 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
487 username = forms.CharField(label=_('User name'), required=False)
465 username = forms.CharField(label=_('User name'), required=False)
488 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
466 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
489
467
490 def clean_username(self):
468 def clean_username(self):
491 username = self.cleaned_data['username']
469 username = self.cleaned_data['username']
492
470
493 if username and not REGEX_USERNAMES.match(username):
471 if username and not REGEX_USERNAMES.match(username):
494 raise forms.ValidationError(_('Inappropriate characters.'))
472 raise forms.ValidationError(_('Inappropriate characters.'))
495
473
496 return username
474 return username
497
475
498
476
499 class SearchForm(NeboardForm):
477 class SearchForm(NeboardForm):
500 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
478 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
@@ -1,409 +1,393 b''
1 import uuid
1 import uuid
2 import hashlib
2 import hashlib
3 import re
3 import re
4
4
5 from boards import settings
5 from boards import settings
6 from boards.abstracts.tripcode import Tripcode
6 from boards.abstracts.tripcode import Tripcode
7 from boards.models import Attachment, KeyPair, GlobalId
7 from boards.models import Attachment, KeyPair, GlobalId
8 from boards.models.attachment import FILE_TYPES_IMAGE
8 from boards.models.attachment import FILE_TYPES_IMAGE
9 from boards.models.base import Viewable
9 from boards.models.base import Viewable
10 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
10 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
11 from boards.models.post.manager import PostManager, NO_IP
11 from boards.models.post.manager import PostManager, NO_IP
12 from boards.utils import datetime_to_epoch
12 from boards.utils import datetime_to_epoch
13 from django.core.exceptions import ObjectDoesNotExist
13 from django.core.exceptions import ObjectDoesNotExist
14 from django.core.urlresolvers import reverse
14 from django.core.urlresolvers import reverse
15 from django.db import models
15 from django.db import models
16 from django.db.models import TextField, QuerySet, F
16 from django.db.models import TextField, QuerySet, F
17 from django.template.defaultfilters import truncatewords, striptags
17 from django.template.defaultfilters import truncatewords, striptags
18 from django.template.loader import render_to_string
18 from django.template.loader import render_to_string
19
19
20 CSS_CLS_HIDDEN_POST = 'hidden_post'
20 CSS_CLS_HIDDEN_POST = 'hidden_post'
21 CSS_CLS_DEAD_POST = 'dead_post'
21 CSS_CLS_DEAD_POST = 'dead_post'
22 CSS_CLS_ARCHIVE_POST = 'archive_post'
22 CSS_CLS_ARCHIVE_POST = 'archive_post'
23 CSS_CLS_POST = 'post'
23 CSS_CLS_POST = 'post'
24 CSS_CLS_MONOCHROME = 'monochrome'
24 CSS_CLS_MONOCHROME = 'monochrome'
25
25
26 TITLE_MAX_WORDS = 10
26 TITLE_MAX_WORDS = 10
27
27
28 APP_LABEL_BOARDS = 'boards'
28 APP_LABEL_BOARDS = 'boards'
29
29
30 BAN_REASON_AUTO = 'Auto'
30 BAN_REASON_AUTO = 'Auto'
31
31
32 TITLE_MAX_LENGTH = 200
32 TITLE_MAX_LENGTH = 200
33
33
34 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
34 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
35 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
35 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
36 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
36 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
37 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
37 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
38
38
39 PARAMETER_TRUNCATED = 'truncated'
39 PARAMETER_TRUNCATED = 'truncated'
40 PARAMETER_TAG = 'tag'
40 PARAMETER_TAG = 'tag'
41 PARAMETER_OFFSET = 'offset'
41 PARAMETER_OFFSET = 'offset'
42 PARAMETER_DIFF_TYPE = 'type'
42 PARAMETER_DIFF_TYPE = 'type'
43 PARAMETER_CSS_CLASS = 'css_class'
43 PARAMETER_CSS_CLASS = 'css_class'
44 PARAMETER_THREAD = 'thread'
44 PARAMETER_THREAD = 'thread'
45 PARAMETER_IS_OPENING = 'is_opening'
45 PARAMETER_IS_OPENING = 'is_opening'
46 PARAMETER_POST = 'post'
46 PARAMETER_POST = 'post'
47 PARAMETER_OP_ID = 'opening_post_id'
47 PARAMETER_OP_ID = 'opening_post_id'
48 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
48 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
49 PARAMETER_REPLY_LINK = 'reply_link'
49 PARAMETER_REPLY_LINK = 'reply_link'
50 PARAMETER_NEED_OP_DATA = 'need_op_data'
50 PARAMETER_NEED_OP_DATA = 'need_op_data'
51
51
52 POST_VIEW_PARAMS = (
52 POST_VIEW_PARAMS = (
53 'need_op_data',
53 'need_op_data',
54 'reply_link',
54 'reply_link',
55 'need_open_link',
55 'need_open_link',
56 'truncated',
56 'truncated',
57 'mode_tree',
57 'mode_tree',
58 'perms',
58 'perms',
59 'tree_depth',
59 'tree_depth',
60 )
60 )
61
61
62
62
63 class Post(models.Model, Viewable):
63 class Post(models.Model, Viewable):
64 """A post is a message."""
64 """A post is a message."""
65
65
66 objects = PostManager()
66 objects = PostManager()
67
67
68 class Meta:
68 class Meta:
69 app_label = APP_LABEL_BOARDS
69 app_label = APP_LABEL_BOARDS
70 ordering = ('id',)
70 ordering = ('id',)
71
71
72 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
72 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
73 pub_time = models.DateTimeField(db_index=True)
73 pub_time = models.DateTimeField(db_index=True)
74 text = TextField(blank=True, null=True)
74 text = TextField(blank=True, null=True)
75 _text_rendered = TextField(blank=True, null=True, editable=False)
75 _text_rendered = TextField(blank=True, null=True, editable=False)
76
76
77 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
77 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
78 related_name='attachment_posts')
78 related_name='attachment_posts')
79
79
80 poster_ip = models.GenericIPAddressField()
80 poster_ip = models.GenericIPAddressField()
81
81
82 # Used for cache and threads updating
82 # Used for cache and threads updating
83 last_edit_time = models.DateTimeField()
83 last_edit_time = models.DateTimeField()
84
84
85 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
85 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
86 null=True,
86 null=True,
87 blank=True, related_name='refposts',
87 blank=True, related_name='refposts',
88 db_index=True)
88 db_index=True)
89 refmap = models.TextField(null=True, blank=True)
89 refmap = models.TextField(null=True, blank=True)
90 threads = models.ManyToManyField('Thread', db_index=True,
90 thread = models.ForeignKey('Thread', db_index=True, related_name='replies')
91 related_name='multi_replies')
92 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
93
91
94 url = models.TextField()
92 url = models.TextField()
95 uid = models.TextField(db_index=True)
93 uid = models.TextField(db_index=True)
96
94
97 # Global ID with author key. If the message was downloaded from another
95 # Global ID with author key. If the message was downloaded from another
98 # server, this indicates the server.
96 # server, this indicates the server.
99 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
97 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
100 on_delete=models.CASCADE)
98 on_delete=models.CASCADE)
101
99
102 tripcode = models.CharField(max_length=50, blank=True, default='')
100 tripcode = models.CharField(max_length=50, blank=True, default='')
103 opening = models.BooleanField(db_index=True)
101 opening = models.BooleanField(db_index=True)
104 hidden = models.BooleanField(default=False)
102 hidden = models.BooleanField(default=False)
105 version = models.IntegerField(default=1)
103 version = models.IntegerField(default=1)
106
104
107 def __str__(self):
105 def __str__(self):
108 return 'P#{}/{}'.format(self.id, self.get_title())
106 return 'P#{}/{}'.format(self.id, self.get_title())
109
107
110 def get_title(self) -> str:
108 def get_title(self) -> str:
111 return self.title
109 return self.title
112
110
113 def get_title_or_text(self):
111 def get_title_or_text(self):
114 title = self.get_title()
112 title = self.get_title()
115 if not title:
113 if not title:
116 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
114 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
117
115
118 return title
116 return title
119
117
120 def build_refmap(self, excluded_ids=None) -> None:
118 def build_refmap(self, excluded_ids=None) -> None:
121 """
119 """
122 Builds a replies map string from replies list. This is a cache to stop
120 Builds a replies map string from replies list. This is a cache to stop
123 the server from recalculating the map on every post show.
121 the server from recalculating the map on every post show.
124 """
122 """
125
123
126 replies = self.referenced_posts
124 replies = self.referenced_posts
127 if excluded_ids is not None:
125 if excluded_ids is not None:
128 replies = replies.exclude(id__in=excluded_ids)
126 replies = replies.exclude(id__in=excluded_ids)
129 else:
127 else:
130 replies = replies.all()
128 replies = replies.all()
131
129
132 post_urls = [refpost.get_link_view() for refpost in replies]
130 post_urls = [refpost.get_link_view() for refpost in replies]
133
131
134 self.refmap = ', '.join(post_urls)
132 self.refmap = ', '.join(post_urls)
135
133
136 def is_referenced(self) -> bool:
134 def is_referenced(self) -> bool:
137 return self.refmap and len(self.refmap) > 0
135 return self.refmap and len(self.refmap) > 0
138
136
139 def is_opening(self) -> bool:
137 def is_opening(self) -> bool:
140 """
138 """
141 Checks if this is an opening post or just a reply.
139 Checks if this is an opening post or just a reply.
142 """
140 """
143
141
144 return self.opening
142 return self.opening
145
143
146 def get_absolute_url(self, thread=None):
144 def get_absolute_url(self, thread=None):
147 # Url is cached only for the "main" thread. When getting url
145 # Url is cached only for the "main" thread. When getting url
148 # for other threads, do it manually.
146 # for other threads, do it manually.
149 return self.url
147 return self.url
150
148
151 def get_thread(self):
149 def get_thread(self):
152 return self.thread
150 return self.thread
153
151
154 def get_thread_id(self):
152 def get_thread_id(self):
155 return self.thread_id
153 return self.thread_id
156
154
157 def get_threads(self) -> QuerySet:
155 def get_threads(self) -> QuerySet:
158 """
156 """
159 Gets post's thread.
157 Gets post's thread.
160 """
158 """
161
159
162 return self.threads
160 return self.threads
163
161
164 def _get_cache_key(self):
162 def _get_cache_key(self):
165 return [datetime_to_epoch(self.last_edit_time)]
163 return [datetime_to_epoch(self.last_edit_time)]
166
164
167 def get_view_params(self, *args, **kwargs):
165 def get_view_params(self, *args, **kwargs):
168 """
166 """
169 Gets the parameters required for viewing the post based on the arguments
167 Gets the parameters required for viewing the post based on the arguments
170 given and the post itself.
168 given and the post itself.
171 """
169 """
172 thread = kwargs.get('thread') or self.get_thread()
170 thread = kwargs.get('thread') or self.get_thread()
173
171
174 css_classes = [CSS_CLS_POST]
172 css_classes = [CSS_CLS_POST]
175 if thread.is_archived():
173 if thread.is_archived():
176 css_classes.append(CSS_CLS_ARCHIVE_POST)
174 css_classes.append(CSS_CLS_ARCHIVE_POST)
177 elif not thread.can_bump():
175 elif not thread.can_bump():
178 css_classes.append(CSS_CLS_DEAD_POST)
176 css_classes.append(CSS_CLS_DEAD_POST)
179 if self.is_hidden():
177 if self.is_hidden():
180 css_classes.append(CSS_CLS_HIDDEN_POST)
178 css_classes.append(CSS_CLS_HIDDEN_POST)
181 if thread.is_monochrome():
179 if thread.is_monochrome():
182 css_classes.append(CSS_CLS_MONOCHROME)
180 css_classes.append(CSS_CLS_MONOCHROME)
183
181
184 params = dict()
182 params = dict()
185 for param in POST_VIEW_PARAMS:
183 for param in POST_VIEW_PARAMS:
186 if param in kwargs:
184 if param in kwargs:
187 params[param] = kwargs[param]
185 params[param] = kwargs[param]
188
186
189 params.update({
187 params.update({
190 PARAMETER_POST: self,
188 PARAMETER_POST: self,
191 PARAMETER_IS_OPENING: self.is_opening(),
189 PARAMETER_IS_OPENING: self.is_opening(),
192 PARAMETER_THREAD: thread,
190 PARAMETER_THREAD: thread,
193 PARAMETER_CSS_CLASS: ' '.join(css_classes),
191 PARAMETER_CSS_CLASS: ' '.join(css_classes),
194 })
192 })
195
193
196 return params
194 return params
197
195
198 def get_view(self, *args, **kwargs) -> str:
196 def get_view(self, *args, **kwargs) -> str:
199 """
197 """
200 Renders post's HTML view. Some of the post params can be passed over
198 Renders post's HTML view. Some of the post params can be passed over
201 kwargs for the means of caching (if we view the thread, some params
199 kwargs for the means of caching (if we view the thread, some params
202 are same for every post and don't need to be computed over and over.
200 are same for every post and don't need to be computed over and over.
203 """
201 """
204 params = self.get_view_params(*args, **kwargs)
202 params = self.get_view_params(*args, **kwargs)
205
203
206 return render_to_string('boards/post.html', params)
204 return render_to_string('boards/post.html', params)
207
205
208 def get_search_view(self, *args, **kwargs):
206 def get_search_view(self, *args, **kwargs):
209 return self.get_view(need_op_data=True, *args, **kwargs)
207 return self.get_view(need_op_data=True, *args, **kwargs)
210
208
211 def get_first_image(self) -> Attachment:
209 def get_first_image(self) -> Attachment:
212 return self.attachments.filter(mimetype__in=FILE_TYPES_IMAGE).earliest('id')
210 return self.attachments.filter(mimetype__in=FILE_TYPES_IMAGE).earliest('id')
213
211
214 def set_global_id(self, key_pair=None):
212 def set_global_id(self, key_pair=None):
215 """
213 """
216 Sets global id based on the given key pair. If no key pair is given,
214 Sets global id based on the given key pair. If no key pair is given,
217 default one is used.
215 default one is used.
218 """
216 """
219
217
220 if key_pair:
218 if key_pair:
221 key = key_pair
219 key = key_pair
222 else:
220 else:
223 try:
221 try:
224 key = KeyPair.objects.get(primary=True)
222 key = KeyPair.objects.get(primary=True)
225 except KeyPair.DoesNotExist:
223 except KeyPair.DoesNotExist:
226 # Do not update the global id because there is no key defined
224 # Do not update the global id because there is no key defined
227 return
225 return
228 global_id = GlobalId(key_type=key.key_type,
226 global_id = GlobalId(key_type=key.key_type,
229 key=key.public_key,
227 key=key.public_key,
230 local_id=self.id)
228 local_id=self.id)
231 global_id.save()
229 global_id.save()
232
230
233 self.global_id = global_id
231 self.global_id = global_id
234
232
235 self.save(update_fields=['global_id'])
233 self.save(update_fields=['global_id'])
236
234
237 def get_pub_time_str(self):
235 def get_pub_time_str(self):
238 return str(self.pub_time)
236 return str(self.pub_time)
239
237
240 def get_replied_ids(self):
238 def get_replied_ids(self):
241 """
239 """
242 Gets ID list of the posts that this post replies.
240 Gets ID list of the posts that this post replies.
243 """
241 """
244
242
245 raw_text = self.get_raw_text()
243 raw_text = self.get_raw_text()
246
244
247 local_replied = REGEX_REPLY.findall(raw_text)
245 local_replied = REGEX_REPLY.findall(raw_text)
248 global_replied = []
246 global_replied = []
249 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
247 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
250 key_type = match[0]
248 key_type = match[0]
251 key = match[1]
249 key = match[1]
252 local_id = match[2]
250 local_id = match[2]
253
251
254 try:
252 try:
255 global_id = GlobalId.objects.get(key_type=key_type,
253 global_id = GlobalId.objects.get(key_type=key_type,
256 key=key, local_id=local_id)
254 key=key, local_id=local_id)
257 for post in Post.objects.filter(global_id=global_id).only('id'):
255 for post in Post.objects.filter(global_id=global_id).only('id'):
258 global_replied.append(post.id)
256 global_replied.append(post.id)
259 except GlobalId.DoesNotExist:
257 except GlobalId.DoesNotExist:
260 pass
258 pass
261 return local_replied + global_replied
259 return local_replied + global_replied
262
260
263 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
261 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
264 include_last_update=False) -> str:
262 include_last_update=False) -> str:
265 """
263 """
266 Gets post HTML or JSON data that can be rendered on a page or used by
264 Gets post HTML or JSON data that can be rendered on a page or used by
267 API.
265 API.
268 """
266 """
269
267
270 return get_exporter(format_type).export(self, request,
268 return get_exporter(format_type).export(self, request,
271 include_last_update)
269 include_last_update)
272
270
273 def notify_clients(self, recursive=True):
271 def notify_clients(self, recursive=True):
274 """
272 """
275 Sends post HTML data to the thread web socket.
273 Sends post HTML data to the thread web socket.
276 """
274 """
277
275
278 if not settings.get_bool('External', 'WebsocketsEnabled'):
276 if not settings.get_bool('External', 'WebsocketsEnabled'):
279 return
277 return
280
278
281 thread_ids = list()
279 thread_ids = list()
282 for thread in self.get_threads().all():
280 self.get_thread().notify_clients()
283 thread_ids.append(thread.id)
284
285 thread.notify_clients()
286
281
287 if recursive:
282 if recursive:
288 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
283 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
289 post_id = reply_number.group(1)
284 post_id = reply_number.group(1)
290
285
291 try:
286 try:
292 ref_post = Post.objects.get(id=post_id)
287 ref_post = Post.objects.get(id=post_id)
293
288
294 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
289 if ref_post.get_thread().id not in thread_ids:
295 # If post is in this thread, its thread was already notified.
290 # If post is in this thread, its thread was already notified.
296 # Otherwise, notify its thread separately.
291 # Otherwise, notify its thread separately.
297 ref_post.notify_clients(recursive=False)
292 ref_post.notify_clients(recursive=False)
298 except ObjectDoesNotExist:
293 except ObjectDoesNotExist:
299 pass
294 pass
300
295
301 def _build_url(self):
296 def _build_url(self):
302 opening = self.is_opening()
297 opening = self.is_opening()
303 opening_id = self.id if opening else self.get_thread().get_opening_post_id()
298 opening_id = self.id if opening else self.get_thread().get_opening_post_id()
304 url = reverse('thread', kwargs={'post_id': opening_id})
299 url = reverse('thread', kwargs={'post_id': opening_id})
305 if not opening:
300 if not opening:
306 url += '#' + str(self.id)
301 url += '#' + str(self.id)
307
302
308 return url
303 return url
309
304
310 def save(self, force_insert=False, force_update=False, using=None,
305 def save(self, force_insert=False, force_update=False, using=None,
311 update_fields=None):
306 update_fields=None):
312 new_post = self.id is None
307 new_post = self.id is None
313
308
314 self.uid = str(uuid.uuid4())
309 self.uid = str(uuid.uuid4())
315 if update_fields is not None and 'uid' not in update_fields:
310 if update_fields is not None and 'uid' not in update_fields:
316 update_fields += ['uid']
311 update_fields += ['uid']
317
312
318 if not new_post:
313 if not new_post:
319 for thread in self.get_threads().all():
314 thread = self.get_thread()
315 if thread:
320 thread.last_edit_time = self.last_edit_time
316 thread.last_edit_time = self.last_edit_time
321
322 thread.save(update_fields=['last_edit_time', 'status'])
317 thread.save(update_fields=['last_edit_time', 'status'])
323
318
324 super().save(force_insert, force_update, using, update_fields)
319 super().save(force_insert, force_update, using, update_fields)
325
320
326 if new_post:
321 if new_post:
327 self.url = self._build_url()
322 self.url = self._build_url()
328 super().save(update_fields=['url'])
323 super().save(update_fields=['url'])
329
324
330 def get_text(self) -> str:
325 def get_text(self) -> str:
331 return self._text_rendered
326 return self._text_rendered
332
327
333 def get_raw_text(self) -> str:
328 def get_raw_text(self) -> str:
334 return self.text
329 return self.text
335
330
336 def get_sync_text(self) -> str:
331 def get_sync_text(self) -> str:
337 """
332 """
338 Returns text applicable for sync. It has absolute post reflinks.
333 Returns text applicable for sync. It has absolute post reflinks.
339 """
334 """
340
335
341 replacements = dict()
336 replacements = dict()
342 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
337 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
343 try:
338 try:
344 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
339 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
345 replacements[post_id] = absolute_post_id
340 replacements[post_id] = absolute_post_id
346 except Post.DoesNotExist:
341 except Post.DoesNotExist:
347 pass
342 pass
348
343
349 text = self.get_raw_text() or ''
344 text = self.get_raw_text() or ''
350 for key in replacements:
345 for key in replacements:
351 text = text.replace('[post]{}[/post]'.format(key),
346 text = text.replace('[post]{}[/post]'.format(key),
352 '[post]{}[/post]'.format(replacements[key]))
347 '[post]{}[/post]'.format(replacements[key]))
353 text = text.replace('\r\n', '\n').replace('\r', '\n')
348 text = text.replace('\r\n', '\n').replace('\r', '\n')
354
349
355 return text
350 return text
356
351
357 def connect_threads(self, opening_posts):
358 for opening_post in opening_posts:
359 threads = opening_post.get_threads().all()
360 for thread in threads:
361 if thread.can_bump():
362 thread.update_bump_status()
363
364 thread.last_edit_time = self.last_edit_time
365 thread.save(update_fields=['last_edit_time', 'status'])
366 self.threads.add(opening_post.get_thread())
367
368 def get_tripcode(self):
352 def get_tripcode(self):
369 if self.tripcode:
353 if self.tripcode:
370 return Tripcode(self.tripcode)
354 return Tripcode(self.tripcode)
371
355
372 def get_link_view(self):
356 def get_link_view(self):
373 """
357 """
374 Gets view of a reflink to the post.
358 Gets view of a reflink to the post.
375 """
359 """
376 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
360 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
377 self.id)
361 self.id)
378 if self.is_opening():
362 if self.is_opening():
379 result = '<b>{}</b>'.format(result)
363 result = '<b>{}</b>'.format(result)
380
364
381 return result
365 return result
382
366
383 def is_hidden(self) -> bool:
367 def is_hidden(self) -> bool:
384 return self.hidden
368 return self.hidden
385
369
386 def set_hidden(self, hidden):
370 def set_hidden(self, hidden):
387 self.hidden = hidden
371 self.hidden = hidden
388
372
389 def increment_version(self):
373 def increment_version(self):
390 self.version = F('version') + 1
374 self.version = F('version') + 1
391
375
392 def clear_cache(self):
376 def clear_cache(self):
393 """
377 """
394 Clears sync data (content cache, signatures etc).
378 Clears sync data (content cache, signatures etc).
395 """
379 """
396 global_id = self.global_id
380 global_id = self.global_id
397 if global_id is not None and global_id.is_local()\
381 if global_id is not None and global_id.is_local()\
398 and global_id.content is not None:
382 and global_id.content is not None:
399 global_id.clear_cache()
383 global_id.clear_cache()
400
384
401 def get_tags(self):
385 def get_tags(self):
402 return self.get_thread().get_tags()
386 return self.get_thread().get_tags()
403
387
404 def get_ip_color(self):
388 def get_ip_color(self):
405 return hashlib.md5(self.poster_ip.encode()).hexdigest()[:6]
389 return hashlib.md5(self.poster_ip.encode()).hexdigest()[:6]
406
390
407 def has_ip(self):
391 def has_ip(self):
408 return self.poster_ip != NO_IP
392 return self.poster_ip != NO_IP
409
393
@@ -1,196 +1,190 b''
1 import logging
1 import logging
2
2
3 from datetime import datetime, timedelta, date
3 from datetime import datetime, timedelta, date
4 from datetime import time as dtime
4 from datetime import time as dtime
5
5
6 from boards.abstracts.exceptions import BannedException, ArchiveException
6 from boards.abstracts.exceptions import BannedException, ArchiveException
7 from django.db import models, transaction
7 from django.db import models, transaction
8 from django.utils import timezone
8 from django.utils import timezone
9 from django.dispatch import Signal
9 from django.dispatch import Signal
10
10
11 import boards
11 import boards
12
12
13 from boards.models.user import Ban
13 from boards.models.user import Ban
14 from boards.mdx_neboard import Parser
14 from boards.mdx_neboard import Parser
15 from boards.models import Attachment
15 from boards.models import Attachment
16 from boards import utils
16 from boards import utils
17
17
18 __author__ = 'neko259'
18 __author__ = 'neko259'
19
19
20 POSTS_PER_DAY_RANGE = 7
20 POSTS_PER_DAY_RANGE = 7
21 NO_IP = '0.0.0.0'
21 NO_IP = '0.0.0.0'
22
22
23
23
24 post_import_deps = Signal()
24 post_import_deps = Signal()
25
25
26
26
27 class PostManager(models.Manager):
27 class PostManager(models.Manager):
28 @transaction.atomic
28 @transaction.atomic
29 def create_post(self, title: str, text: str, file=None, thread=None,
29 def create_post(self, title: str, text: str, file=None, thread=None,
30 ip=NO_IP, tags: list=None, opening_posts: list=None,
30 ip=NO_IP, tags: list=None,
31 tripcode='', monochrome=False, images=[],
31 tripcode='', monochrome=False, images=[],
32 file_url=None):
32 file_url=None):
33 """
33 """
34 Creates new post
34 Creates new post
35 """
35 """
36
36
37 if thread is not None and thread.is_archived():
37 if thread is not None and thread.is_archived():
38 raise ArchiveException('Cannot post into an archived thread')
38 raise ArchiveException('Cannot post into an archived thread')
39
39
40 if not utils.is_anonymous_mode():
40 if not utils.is_anonymous_mode():
41 is_banned = Ban.objects.filter(ip=ip).exists()
41 is_banned = Ban.objects.filter(ip=ip).exists()
42 else:
42 else:
43 is_banned = False
43 is_banned = False
44
44
45 if is_banned:
45 if is_banned:
46 raise BannedException("This user is banned")
46 raise BannedException("This user is banned")
47
47
48 if not tags:
48 if not tags:
49 tags = []
49 tags = []
50 if not opening_posts:
51 opening_posts = []
52
50
53 posting_time = timezone.now()
51 posting_time = timezone.now()
54 new_thread = False
52 new_thread = False
55 if not thread:
53 if not thread:
56 thread = boards.models.thread.Thread.objects.create(
54 thread = boards.models.thread.Thread.objects.create(
57 bump_time=posting_time, last_edit_time=posting_time,
55 bump_time=posting_time, last_edit_time=posting_time,
58 monochrome=monochrome)
56 monochrome=monochrome)
59 list(map(thread.tags.add, tags))
57 list(map(thread.tags.add, tags))
60 new_thread = True
58 new_thread = True
61
59
62 pre_text = Parser().preparse(text)
60 pre_text = Parser().preparse(text)
63
61
64 post = self.create(title=title,
62 post = self.create(title=title,
65 text=pre_text,
63 text=pre_text,
66 pub_time=posting_time,
64 pub_time=posting_time,
67 poster_ip=ip,
65 poster_ip=ip,
68 thread=thread,
66 thread=thread,
69 last_edit_time=posting_time,
67 last_edit_time=posting_time,
70 tripcode=tripcode,
68 tripcode=tripcode,
71 opening=new_thread)
69 opening=new_thread)
72 post.threads.add(thread)
73
70
74 logger = logging.getLogger('boards.post.create')
71 logger = logging.getLogger('boards.post.create')
75
72
76 logger.info('Created post [{}] with text [{}] by {}'.format(post,
73 logger.info('Created post [{}] with text [{}] by {}'.format(post,
77 post.get_text(),post.poster_ip))
74 post.get_text(),post.poster_ip))
78
75
79 if file:
76 if file:
80 self._add_file_to_post(file, post)
77 self._add_file_to_post(file, post)
81 for image in images:
78 for image in images:
82 post.attachments.add(image)
79 post.attachments.add(image)
83 if file_url:
80 if file_url:
84 post.attachments.add(Attachment.objects.create_from_url(file_url))
81 post.attachments.add(Attachment.objects.create_from_url(file_url))
85
82
86 post.connect_threads(opening_posts)
87 post.set_global_id()
83 post.set_global_id()
88
84
89 # Thread needs to be bumped only when the post is already created
85 # Thread needs to be bumped only when the post is already created
90 if not new_thread:
86 if not new_thread:
91 thread.last_edit_time = posting_time
87 thread.last_edit_time = posting_time
92 thread.bump()
88 thread.bump()
93 thread.save()
89 thread.save()
94
90
95 return post
91 return post
96
92
97 def delete_posts_by_ip(self, ip):
93 def delete_posts_by_ip(self, ip):
98 """
94 """
99 Deletes all posts of the author with same IP
95 Deletes all posts of the author with same IP
100 """
96 """
101
97
102 posts = self.filter(poster_ip=ip)
98 posts = self.filter(poster_ip=ip)
103 for post in posts:
99 for post in posts:
104 post.delete()
100 post.delete()
105
101
106 @utils.cached_result()
102 @utils.cached_result()
107 def get_posts_per_day(self) -> float:
103 def get_posts_per_day(self) -> float:
108 """
104 """
109 Gets average count of posts per day for the last 7 days
105 Gets average count of posts per day for the last 7 days
110 """
106 """
111
107
112 day_end = date.today()
108 day_end = date.today()
113 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
109 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
114
110
115 day_time_start = timezone.make_aware(datetime.combine(
111 day_time_start = timezone.make_aware(datetime.combine(
116 day_start, dtime()), timezone.get_current_timezone())
112 day_start, dtime()), timezone.get_current_timezone())
117 day_time_end = timezone.make_aware(datetime.combine(
113 day_time_end = timezone.make_aware(datetime.combine(
118 day_end, dtime()), timezone.get_current_timezone())
114 day_end, dtime()), timezone.get_current_timezone())
119
115
120 posts_per_period = float(self.filter(
116 posts_per_period = float(self.filter(
121 pub_time__lte=day_time_end,
117 pub_time__lte=day_time_end,
122 pub_time__gte=day_time_start).count())
118 pub_time__gte=day_time_start).count())
123
119
124 ppd = posts_per_period / POSTS_PER_DAY_RANGE
120 ppd = posts_per_period / POSTS_PER_DAY_RANGE
125
121
126 return ppd
122 return ppd
127
123
128 def get_post_per_days(self, days) -> int:
124 def get_post_per_days(self, days) -> int:
129 day_end = date.today() + timedelta(1)
125 day_end = date.today() + timedelta(1)
130 day_start = day_end - timedelta(days)
126 day_start = day_end - timedelta(days)
131
127
132 day_time_start = timezone.make_aware(datetime.combine(
128 day_time_start = timezone.make_aware(datetime.combine(
133 day_start, dtime()), timezone.get_current_timezone())
129 day_start, dtime()), timezone.get_current_timezone())
134 day_time_end = timezone.make_aware(datetime.combine(
130 day_time_end = timezone.make_aware(datetime.combine(
135 day_end, dtime()), timezone.get_current_timezone())
131 day_end, dtime()), timezone.get_current_timezone())
136
132
137 return self.filter(
133 return self.filter(
138 pub_time__lte=day_time_end,
134 pub_time__lte=day_time_end,
139 pub_time__gte=day_time_start).count()
135 pub_time__gte=day_time_start).count()
140
136
141
137
142 @transaction.atomic
138 @transaction.atomic
143 def import_post(self, title: str, text: str, pub_time: str, global_id,
139 def import_post(self, title: str, text: str, pub_time: str, global_id,
144 opening_post=None, tags=list(), files=list(),
140 opening_post=None, tags=list(), files=list(),
145 tripcode=None, version=1):
141 tripcode=None, version=1):
146 is_opening = opening_post is None
142 is_opening = opening_post is None
147 if is_opening:
143 if is_opening:
148 thread = boards.models.thread.Thread.objects.create(
144 thread = boards.models.thread.Thread.objects.create(
149 bump_time=pub_time, last_edit_time=pub_time)
145 bump_time=pub_time, last_edit_time=pub_time)
150 list(map(thread.tags.add, tags))
146 list(map(thread.tags.add, tags))
151 else:
147 else:
152 thread = opening_post.get_thread()
148 thread = opening_post.get_thread()
153
149
154 post = self.create(title=title,
150 post = self.create(title=title,
155 text=text,
151 text=text,
156 pub_time=pub_time,
152 pub_time=pub_time,
157 poster_ip=NO_IP,
153 poster_ip=NO_IP,
158 last_edit_time=pub_time,
154 last_edit_time=pub_time,
159 global_id=global_id,
155 global_id=global_id,
160 opening=is_opening,
156 opening=is_opening,
161 thread=thread,
157 thread=thread,
162 tripcode=tripcode,
158 tripcode=tripcode,
163 version=version)
159 version=version)
164
160
165 for file in files:
161 for file in files:
166 self._add_file_to_post(file, post)
162 self._add_file_to_post(file, post)
167
163
168 post.threads.add(thread)
169
170 url_to_post = '[post]{}[/post]'.format(str(global_id))
164 url_to_post = '[post]{}[/post]'.format(str(global_id))
171 replies = self.filter(text__contains=url_to_post)
165 replies = self.filter(text__contains=url_to_post)
172 for reply in replies:
166 for reply in replies:
173 post_import_deps.send(reply)
167 post_import_deps.send(reply)
174
168
175 @transaction.atomic
169 @transaction.atomic
176 def update_post(self, post, title: str, text: str, pub_time: str,
170 def update_post(self, post, title: str, text: str, pub_time: str,
177 tags=list(), files=list(), tripcode=None, version=1):
171 tags=list(), files=list(), tripcode=None, version=1):
178 post.title = title
172 post.title = title
179 post.text = text
173 post.text = text
180 post.pub_time = pub_time
174 post.pub_time = pub_time
181 post.tripcode = tripcode
175 post.tripcode = tripcode
182 post.version = version
176 post.version = version
183 post.save()
177 post.save()
184
178
185 post.clear_cache()
179 post.clear_cache()
186
180
187 post.attachments.clear()
181 post.attachments.clear()
188 for file in files:
182 for file in files:
189 self._add_file_to_post(file, post)
183 self._add_file_to_post(file, post)
190
184
191 thread = post.get_thread()
185 thread = post.get_thread()
192 thread.tags.clear()
186 thread.tags.clear()
193 list(map(thread.tags.add, tags))
187 list(map(thread.tags.add, tags))
194
188
195 def _add_file_to_post(self, file, post):
189 def _add_file_to_post(self, file, post):
196 post.attachments.add(Attachment.objects.create_with_hash(file))
190 post.attachments.add(Attachment.objects.create_with_hash(file))
@@ -1,150 +1,150 b''
1 import hashlib
1 import hashlib
2 from boards.models.attachment import FILE_TYPES_IMAGE
2 from boards.models.attachment import FILE_TYPES_IMAGE
3 from django.template.loader import render_to_string
3 from django.template.loader import render_to_string
4 from django.db import models
4 from django.db import models
5 from django.db.models import Count
5 from django.db.models import Count
6 from django.core.urlresolvers import reverse
6 from django.core.urlresolvers import reverse
7
7
8 from boards.models import Attachment
8 from boards.models import Attachment
9 from boards.models.base import Viewable
9 from boards.models.base import Viewable
10 from boards.models.thread import STATUS_ACTIVE, STATUS_BUMPLIMIT, STATUS_ARCHIVE
10 from boards.models.thread import STATUS_ACTIVE, STATUS_BUMPLIMIT, STATUS_ARCHIVE
11 from boards.utils import cached_result
11 from boards.utils import cached_result
12 import boards
12 import boards
13
13
14 __author__ = 'neko259'
14 __author__ = 'neko259'
15
15
16
16
17 RELATED_TAGS_COUNT = 5
17 RELATED_TAGS_COUNT = 5
18
18
19
19
20 class TagManager(models.Manager):
20 class TagManager(models.Manager):
21
21
22 def get_not_empty_tags(self):
22 def get_not_empty_tags(self):
23 """
23 """
24 Gets tags that have non-archived threads.
24 Gets tags that have non-archived threads.
25 """
25 """
26
26
27 return self.annotate(num_threads=Count('thread_tags')).filter(num_threads__gt=0)\
27 return self.annotate(num_threads=Count('thread_tags')).filter(num_threads__gt=0)\
28 .order_by('-required', 'name')
28 .order_by('-required', 'name')
29
29
30 def get_tag_url_list(self, tags: list) -> str:
30 def get_tag_url_list(self, tags: list) -> str:
31 """
31 """
32 Gets a comma-separated list of tag links.
32 Gets a comma-separated list of tag links.
33 """
33 """
34
34
35 return ', '.join([tag.get_view() for tag in tags])
35 return ', '.join([tag.get_view() for tag in tags])
36
36
37
37
38 class Tag(models.Model, Viewable):
38 class Tag(models.Model, Viewable):
39 """
39 """
40 A tag is a text node assigned to the thread. The tag serves as a board
40 A tag is a text node assigned to the thread. The tag serves as a board
41 section. There can be multiple tags for each thread
41 section. There can be multiple tags for each thread
42 """
42 """
43
43
44 objects = TagManager()
44 objects = TagManager()
45
45
46 class Meta:
46 class Meta:
47 app_label = 'boards'
47 app_label = 'boards'
48 ordering = ('name',)
48 ordering = ('name',)
49
49
50 name = models.CharField(max_length=100, db_index=True, unique=True)
50 name = models.CharField(max_length=100, db_index=True, unique=True)
51 required = models.BooleanField(default=False, db_index=True)
51 required = models.BooleanField(default=False, db_index=True)
52 description = models.TextField(blank=True)
52 description = models.TextField(blank=True)
53
53
54 parent = models.ForeignKey('Tag', null=True, blank=True,
54 parent = models.ForeignKey('Tag', null=True, blank=True,
55 related_name='children')
55 related_name='children')
56
56
57 def __str__(self):
57 def __str__(self):
58 return self.name
58 return self.name
59
59
60 def is_empty(self) -> bool:
60 def is_empty(self) -> bool:
61 """
61 """
62 Checks if the tag has some threads.
62 Checks if the tag has some threads.
63 """
63 """
64
64
65 return self.get_thread_count() == 0
65 return self.get_thread_count() == 0
66
66
67 def get_thread_count(self, status=None) -> int:
67 def get_thread_count(self, status=None) -> int:
68 threads = self.get_threads()
68 threads = self.get_threads()
69 if status is not None:
69 if status is not None:
70 threads = threads.filter(status=status)
70 threads = threads.filter(status=status)
71 return threads.count()
71 return threads.count()
72
72
73 def get_active_thread_count(self) -> int:
73 def get_active_thread_count(self) -> int:
74 return self.get_thread_count(status=STATUS_ACTIVE)
74 return self.get_thread_count(status=STATUS_ACTIVE)
75
75
76 def get_bumplimit_thread_count(self) -> int:
76 def get_bumplimit_thread_count(self) -> int:
77 return self.get_thread_count(status=STATUS_BUMPLIMIT)
77 return self.get_thread_count(status=STATUS_BUMPLIMIT)
78
78
79 def get_archived_thread_count(self) -> int:
79 def get_archived_thread_count(self) -> int:
80 return self.get_thread_count(status=STATUS_ARCHIVE)
80 return self.get_thread_count(status=STATUS_ARCHIVE)
81
81
82 def get_absolute_url(self):
82 def get_absolute_url(self):
83 return reverse('tag', kwargs={'tag_name': self.name})
83 return reverse('tag', kwargs={'tag_name': self.name})
84
84
85 def get_threads(self):
85 def get_threads(self):
86 return self.thread_tags.order_by('-bump_time')
86 return self.thread_tags.order_by('-bump_time')
87
87
88 def is_required(self):
88 def is_required(self):
89 return self.required
89 return self.required
90
90
91 def get_view(self):
91 def get_view(self):
92 link = '<a class="tag" href="{}">{}</a>'.format(
92 link = '<a class="tag" href="{}">{}</a>'.format(
93 self.get_absolute_url(), self.name)
93 self.get_absolute_url(), self.name)
94 if self.is_required():
94 if self.is_required():
95 link = '<b>{}</b>'.format(link)
95 link = '<b>{}</b>'.format(link)
96 return link
96 return link
97
97
98 def get_search_view(self, *args, **kwargs):
98 def get_search_view(self, *args, **kwargs):
99 return render_to_string('boards/tag.html', {
99 return render_to_string('boards/tag.html', {
100 'tag': self,
100 'tag': self,
101 })
101 })
102
102
103 @cached_result()
103 @cached_result()
104 def get_post_count(self):
104 def get_post_count(self):
105 return self.get_threads().aggregate(num_posts=Count('multi_replies'))['num_posts']
105 return self.get_threads().aggregate(num_posts=Count('replies'))['num_posts']
106
106
107 def get_description(self):
107 def get_description(self):
108 return self.description
108 return self.description
109
109
110 def get_random_image_post(self, status=[STATUS_ACTIVE, STATUS_BUMPLIMIT]):
110 def get_random_image_post(self, status=[STATUS_ACTIVE, STATUS_BUMPLIMIT]):
111 posts = boards.models.Post.objects.filter(attachments__mimetype__in=FILE_TYPES_IMAGE)\
111 posts = boards.models.Post.objects.filter(attachments__mimetype__in=FILE_TYPES_IMAGE)\
112 .annotate(images_count=Count(
112 .annotate(images_count=Count(
113 'attachments')).filter(images_count__gt=0, threads__tags__in=[self])
113 'attachments')).filter(images_count__gt=0, thread__tags__in=[self])
114 if status is not None:
114 if status is not None:
115 posts = posts.filter(thread__status__in=status)
115 posts = posts.filter(thread__status__in=status)
116 return posts.order_by('?').first()
116 return posts.order_by('?').first()
117
117
118 def get_first_letter(self):
118 def get_first_letter(self):
119 return self.name and self.name[0] or ''
119 return self.name and self.name[0] or ''
120
120
121 def get_related_tags(self):
121 def get_related_tags(self):
122 return set(Tag.objects.filter(thread_tags__in=self.get_threads()).exclude(
122 return set(Tag.objects.filter(thread_tags__in=self.get_threads()).exclude(
123 id=self.id).order_by('?')[:RELATED_TAGS_COUNT])
123 id=self.id).order_by('?')[:RELATED_TAGS_COUNT])
124
124
125 @cached_result()
125 @cached_result()
126 def get_color(self):
126 def get_color(self):
127 """
127 """
128 Gets color hashed from the tag name.
128 Gets color hashed from the tag name.
129 """
129 """
130 return hashlib.md5(self.name.encode()).hexdigest()[:6]
130 return hashlib.md5(self.name.encode()).hexdigest()[:6]
131
131
132 def get_parent(self):
132 def get_parent(self):
133 return self.parent
133 return self.parent
134
134
135 def get_all_parents(self):
135 def get_all_parents(self):
136 parents = list()
136 parents = list()
137 parent = self.get_parent()
137 parent = self.get_parent()
138 if parent and parent not in parents:
138 if parent and parent not in parents:
139 parents.insert(0, parent)
139 parents.insert(0, parent)
140 parents = parent.get_all_parents() + parents
140 parents = parent.get_all_parents() + parents
141
141
142 return parents
142 return parents
143
143
144 def get_children(self):
144 def get_children(self):
145 return self.children
145 return self.children
146
146
147 def get_images(self):
147 def get_images(self):
148 return Attachment.objects.filter(
148 return Attachment.objects.filter(
149 attachment_posts__thread__tags__in=[self]).filter(
149 attachment_posts__thread__tags__in=[self]).filter(
150 mimetype__in=FILE_TYPES_IMAGE).order_by('-attachment_posts__pub_time') No newline at end of file
150 mimetype__in=FILE_TYPES_IMAGE).order_by('-attachment_posts__pub_time')
@@ -1,327 +1,327 b''
1 import logging
1 import logging
2 from adjacent import Client
2 from adjacent import Client
3 from datetime import timedelta
3 from datetime import timedelta
4
4
5
5
6 from django.db.models import Count, Sum, QuerySet, Q
6 from django.db.models import Count, Sum, QuerySet, Q
7 from django.utils import timezone
7 from django.utils import timezone
8 from django.db import models, transaction
8 from django.db import models, transaction
9
9
10 from boards.models.attachment import FILE_TYPES_IMAGE
10 from boards.models.attachment import FILE_TYPES_IMAGE
11 from boards.models import STATUS_BUMPLIMIT, STATUS_ACTIVE, STATUS_ARCHIVE
11 from boards.models import STATUS_BUMPLIMIT, STATUS_ACTIVE, STATUS_ARCHIVE
12
12
13 from boards import settings
13 from boards import settings
14 import boards
14 import boards
15 from boards.utils import cached_result, datetime_to_epoch
15 from boards.utils import cached_result, datetime_to_epoch
16 from boards.models.post import Post
16 from boards.models.post import Post
17 from boards.models.tag import Tag
17 from boards.models.tag import Tag
18
18
19 FAV_THREAD_NO_UPDATES = -1
19 FAV_THREAD_NO_UPDATES = -1
20
20
21
21
22 __author__ = 'neko259'
22 __author__ = 'neko259'
23
23
24
24
25 logger = logging.getLogger(__name__)
25 logger = logging.getLogger(__name__)
26
26
27
27
28 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
28 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
29 WS_NOTIFICATION_TYPE = 'notification_type'
29 WS_NOTIFICATION_TYPE = 'notification_type'
30
30
31 WS_CHANNEL_THREAD = "thread:"
31 WS_CHANNEL_THREAD = "thread:"
32
32
33 STATUS_CHOICES = (
33 STATUS_CHOICES = (
34 (STATUS_ACTIVE, STATUS_ACTIVE),
34 (STATUS_ACTIVE, STATUS_ACTIVE),
35 (STATUS_BUMPLIMIT, STATUS_BUMPLIMIT),
35 (STATUS_BUMPLIMIT, STATUS_BUMPLIMIT),
36 (STATUS_ARCHIVE, STATUS_ARCHIVE),
36 (STATUS_ARCHIVE, STATUS_ARCHIVE),
37 )
37 )
38
38
39
39
40 class ThreadManager(models.Manager):
40 class ThreadManager(models.Manager):
41 def process_old_threads(self):
41 def process_old_threads(self):
42 """
42 """
43 Preserves maximum thread count. If there are too many threads,
43 Preserves maximum thread count. If there are too many threads,
44 archive or delete the old ones.
44 archive or delete the old ones.
45 """
45 """
46 old_time_delta = settings.get_int('Messages', 'ThreadArchiveDays')
46 old_time_delta = settings.get_int('Messages', 'ThreadArchiveDays')
47 old_time = timezone.now() - timedelta(days=old_time_delta)
47 old_time = timezone.now() - timedelta(days=old_time_delta)
48 old_ops = Post.objects.filter(opening=True, pub_time__lte=old_time).exclude(thread__status=STATUS_ARCHIVE)
48 old_ops = Post.objects.filter(opening=True, pub_time__lte=old_time).exclude(thread__status=STATUS_ARCHIVE)
49
49
50 for op in old_ops:
50 for op in old_ops:
51 thread = op.get_thread()
51 thread = op.get_thread()
52 if settings.get_bool('Storage', 'ArchiveThreads'):
52 if settings.get_bool('Storage', 'ArchiveThreads'):
53 self._archive_thread(thread)
53 self._archive_thread(thread)
54 else:
54 else:
55 thread.delete()
55 thread.delete()
56 logger.info('Processed old thread {}'.format(thread))
56 logger.info('Processed old thread {}'.format(thread))
57
57
58
58
59 def _archive_thread(self, thread):
59 def _archive_thread(self, thread):
60 thread.status = STATUS_ARCHIVE
60 thread.status = STATUS_ARCHIVE
61 thread.last_edit_time = timezone.now()
61 thread.last_edit_time = timezone.now()
62 thread.update_posts_time()
62 thread.update_posts_time()
63 thread.save(update_fields=['last_edit_time', 'status'])
63 thread.save(update_fields=['last_edit_time', 'status'])
64
64
65 def get_new_posts(self, datas):
65 def get_new_posts(self, datas):
66 query = None
66 query = None
67 # TODO Use classes instead of dicts
67 # TODO Use classes instead of dicts
68 for data in datas:
68 for data in datas:
69 if data['last_id'] != FAV_THREAD_NO_UPDATES:
69 if data['last_id'] != FAV_THREAD_NO_UPDATES:
70 q = (Q(id=data['op'].get_thread_id())
70 q = (Q(id=data['op'].get_thread_id())
71 & Q(multi_replies__id__gt=data['last_id']))
71 & Q(multi_replies__id__gt=data['last_id']))
72 if query is None:
72 if query is None:
73 query = q
73 query = q
74 else:
74 else:
75 query = query | q
75 query = query | q
76 if query is not None:
76 if query is not None:
77 return self.filter(query).annotate(
77 return self.filter(query).annotate(
78 new_post_count=Count('multi_replies'))
78 new_post_count=Count('multi_replies'))
79
79
80 def get_new_post_count(self, datas):
80 def get_new_post_count(self, datas):
81 new_posts = self.get_new_posts(datas)
81 new_posts = self.get_new_posts(datas)
82 return new_posts.aggregate(total_count=Count('multi_replies'))\
82 return new_posts.aggregate(total_count=Count('multi_replies'))\
83 ['total_count'] if new_posts else 0
83 ['total_count'] if new_posts else 0
84
84
85
85
86 def get_thread_max_posts():
86 def get_thread_max_posts():
87 return settings.get_int('Messages', 'MaxPostsPerThread')
87 return settings.get_int('Messages', 'MaxPostsPerThread')
88
88
89
89
90 class Thread(models.Model):
90 class Thread(models.Model):
91 objects = ThreadManager()
91 objects = ThreadManager()
92
92
93 class Meta:
93 class Meta:
94 app_label = 'boards'
94 app_label = 'boards'
95
95
96 tags = models.ManyToManyField('Tag', related_name='thread_tags')
96 tags = models.ManyToManyField('Tag', related_name='thread_tags')
97 bump_time = models.DateTimeField(db_index=True)
97 bump_time = models.DateTimeField(db_index=True)
98 last_edit_time = models.DateTimeField()
98 last_edit_time = models.DateTimeField()
99 max_posts = models.IntegerField(default=get_thread_max_posts)
99 max_posts = models.IntegerField(default=get_thread_max_posts)
100 status = models.CharField(max_length=50, default=STATUS_ACTIVE,
100 status = models.CharField(max_length=50, default=STATUS_ACTIVE,
101 choices=STATUS_CHOICES, db_index=True)
101 choices=STATUS_CHOICES, db_index=True)
102 monochrome = models.BooleanField(default=False)
102 monochrome = models.BooleanField(default=False)
103
103
104 def get_tags(self) -> QuerySet:
104 def get_tags(self) -> QuerySet:
105 """
105 """
106 Gets a sorted tag list.
106 Gets a sorted tag list.
107 """
107 """
108
108
109 return self.tags.order_by('name')
109 return self.tags.order_by('name')
110
110
111 def bump(self):
111 def bump(self):
112 """
112 """
113 Bumps (moves to up) thread if possible.
113 Bumps (moves to up) thread if possible.
114 """
114 """
115
115
116 if self.can_bump():
116 if self.can_bump():
117 self.bump_time = self.last_edit_time
117 self.bump_time = self.last_edit_time
118
118
119 self.update_bump_status()
119 self.update_bump_status()
120
120
121 logger.info('Bumped thread %d' % self.id)
121 logger.info('Bumped thread %d' % self.id)
122
122
123 def has_post_limit(self) -> bool:
123 def has_post_limit(self) -> bool:
124 return self.max_posts > 0
124 return self.max_posts > 0
125
125
126 def update_bump_status(self, exclude_posts=None):
126 def update_bump_status(self, exclude_posts=None):
127 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
127 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
128 self.status = STATUS_BUMPLIMIT
128 self.status = STATUS_BUMPLIMIT
129 self.update_posts_time(exclude_posts=exclude_posts)
129 self.update_posts_time(exclude_posts=exclude_posts)
130
130
131 def _get_cache_key(self):
131 def _get_cache_key(self):
132 return [datetime_to_epoch(self.last_edit_time)]
132 return [datetime_to_epoch(self.last_edit_time)]
133
133
134 @cached_result(key_method=_get_cache_key)
134 @cached_result(key_method=_get_cache_key)
135 def get_reply_count(self) -> int:
135 def get_reply_count(self) -> int:
136 return self.get_replies().count()
136 return self.get_replies().count()
137
137
138 @cached_result(key_method=_get_cache_key)
138 @cached_result(key_method=_get_cache_key)
139 def get_images_count(self) -> int:
139 def get_images_count(self) -> int:
140 return self.get_replies().filter(
140 return self.get_replies().filter(
141 attachments__mimetype__in=FILE_TYPES_IMAGE)\
141 attachments__mimetype__in=FILE_TYPES_IMAGE)\
142 .annotate(images_count=Count(
142 .annotate(images_count=Count(
143 'attachments')).aggregate(Sum('images_count'))['images_count__sum'] or 0
143 'attachments')).aggregate(Sum('images_count'))['images_count__sum'] or 0
144
144
145 def can_bump(self) -> bool:
145 def can_bump(self) -> bool:
146 """
146 """
147 Checks if the thread can be bumped by replying to it.
147 Checks if the thread can be bumped by replying to it.
148 """
148 """
149
149
150 return self.get_status() == STATUS_ACTIVE
150 return self.get_status() == STATUS_ACTIVE
151
151
152 def get_last_replies(self) -> QuerySet:
152 def get_last_replies(self) -> QuerySet:
153 """
153 """
154 Gets several last replies, not including opening post
154 Gets several last replies, not including opening post
155 """
155 """
156
156
157 last_replies_count = settings.get_int('View', 'LastRepliesCount')
157 last_replies_count = settings.get_int('View', 'LastRepliesCount')
158
158
159 if last_replies_count > 0:
159 if last_replies_count > 0:
160 reply_count = self.get_reply_count()
160 reply_count = self.get_reply_count()
161
161
162 if reply_count > 0:
162 if reply_count > 0:
163 reply_count_to_show = min(last_replies_count,
163 reply_count_to_show = min(last_replies_count,
164 reply_count - 1)
164 reply_count - 1)
165 replies = self.get_replies()
165 replies = self.get_replies()
166 last_replies = replies[reply_count - reply_count_to_show:]
166 last_replies = replies[reply_count - reply_count_to_show:]
167
167
168 return last_replies
168 return last_replies
169
169
170 def get_skipped_replies_count(self) -> int:
170 def get_skipped_replies_count(self) -> int:
171 """
171 """
172 Gets number of posts between opening post and last replies.
172 Gets number of posts between opening post and last replies.
173 """
173 """
174 reply_count = self.get_reply_count()
174 reply_count = self.get_reply_count()
175 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
175 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
176 reply_count - 1)
176 reply_count - 1)
177 return reply_count - last_replies_count - 1
177 return reply_count - last_replies_count - 1
178
178
179 # TODO Remove argument, it is not used
179 # TODO Remove argument, it is not used
180 def get_replies(self, view_fields_only=True) -> QuerySet:
180 def get_replies(self, view_fields_only=True) -> QuerySet:
181 """
181 """
182 Gets sorted thread posts
182 Gets sorted thread posts
183 """
183 """
184 query = self.multi_replies.order_by('pub_time').prefetch_related(
184 query = self.replies.order_by('pub_time').prefetch_related(
185 'thread', 'attachments')
185 'attachments')
186 return query
186 return query
187
187
188 def get_viewable_replies(self) -> QuerySet:
188 def get_viewable_replies(self) -> QuerySet:
189 """
189 """
190 Gets replies with only fields that are used for viewing.
190 Gets replies with only fields that are used for viewing.
191 """
191 """
192 return self.get_replies().defer('text', 'last_edit_time', 'version')
192 return self.get_replies().defer('text', 'last_edit_time', 'version')
193
193
194 def get_top_level_replies(self) -> QuerySet:
194 def get_top_level_replies(self) -> QuerySet:
195 return self.get_replies().exclude(refposts__threads__in=[self])
195 return self.get_replies().exclude(refposts__threads__in=[self])
196
196
197 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
197 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
198 """
198 """
199 Gets replies that have at least one image attached
199 Gets replies that have at least one image attached
200 """
200 """
201 return self.get_replies(view_fields_only).filter(
201 return self.get_replies(view_fields_only).filter(
202 attachments__mimetype__in=FILE_TYPES_IMAGE).annotate(images_count=Count(
202 attachments__mimetype__in=FILE_TYPES_IMAGE).annotate(images_count=Count(
203 'attachments')).filter(images_count__gt=0)
203 'attachments')).filter(images_count__gt=0)
204
204
205 def get_opening_post(self, only_id=False) -> Post:
205 def get_opening_post(self, only_id=False) -> Post:
206 """
206 """
207 Gets the first post of the thread
207 Gets the first post of the thread
208 """
208 """
209
209
210 query = self.get_replies().filter(opening=True)
210 query = self.get_replies().filter(opening=True)
211 if only_id:
211 if only_id:
212 query = query.only('id')
212 query = query.only('id')
213 opening_post = query.first()
213 opening_post = query.first()
214
214
215 return opening_post
215 return opening_post
216
216
217 @cached_result()
217 @cached_result()
218 def get_opening_post_id(self) -> int:
218 def get_opening_post_id(self) -> int:
219 """
219 """
220 Gets ID of the first thread post.
220 Gets ID of the first thread post.
221 """
221 """
222
222
223 return self.get_opening_post(only_id=True).id
223 return self.get_opening_post(only_id=True).id
224
224
225 def get_pub_time(self):
225 def get_pub_time(self):
226 """
226 """
227 Gets opening post's pub time because thread does not have its own one.
227 Gets opening post's pub time because thread does not have its own one.
228 """
228 """
229
229
230 return self.get_opening_post().pub_time
230 return self.get_opening_post().pub_time
231
231
232 def __str__(self):
232 def __str__(self):
233 return 'T#{}'.format(self.id)
233 return 'T#{}'.format(self.id)
234
234
235 def get_tag_url_list(self) -> list:
235 def get_tag_url_list(self) -> list:
236 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
236 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
237
237
238 def update_posts_time(self, exclude_posts=None):
238 def update_posts_time(self, exclude_posts=None):
239 last_edit_time = self.last_edit_time
239 last_edit_time = self.last_edit_time
240
240
241 for post in self.multi_replies.all():
241 for post in self.multi_replies.all():
242 if exclude_posts is None or post not in exclude_posts:
242 if exclude_posts is None or post not in exclude_posts:
243 # Manual update is required because uids are generated on save
243 # Manual update is required because uids are generated on save
244 post.last_edit_time = last_edit_time
244 post.last_edit_time = last_edit_time
245 post.save(update_fields=['last_edit_time'])
245 post.save(update_fields=['last_edit_time'])
246
246
247 post.get_threads().update(last_edit_time=last_edit_time)
247 post.get_threads().update(last_edit_time=last_edit_time)
248
248
249 def notify_clients(self):
249 def notify_clients(self):
250 if not settings.get_bool('External', 'WebsocketsEnabled'):
250 if not settings.get_bool('External', 'WebsocketsEnabled'):
251 return
251 return
252
252
253 client = Client()
253 client = Client()
254
254
255 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
255 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
256 client.publish(channel_name, {
256 client.publish(channel_name, {
257 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
257 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
258 })
258 })
259 client.send()
259 client.send()
260
260
261 def get_absolute_url(self):
261 def get_absolute_url(self):
262 return self.get_opening_post().get_absolute_url()
262 return self.get_opening_post().get_absolute_url()
263
263
264 def get_required_tags(self):
264 def get_required_tags(self):
265 return self.get_tags().filter(required=True)
265 return self.get_tags().filter(required=True)
266
266
267 def get_replies_newer(self, post_id):
267 def get_replies_newer(self, post_id):
268 return self.get_replies().filter(id__gt=post_id)
268 return self.get_replies().filter(id__gt=post_id)
269
269
270 def is_archived(self):
270 def is_archived(self):
271 return self.get_status() == STATUS_ARCHIVE
271 return self.get_status() == STATUS_ARCHIVE
272
272
273 def get_status(self):
273 def get_status(self):
274 return self.status
274 return self.status
275
275
276 def is_monochrome(self):
276 def is_monochrome(self):
277 return self.monochrome
277 return self.monochrome
278
278
279 # If tags have parent, add them to the tag list
279 # If tags have parent, add them to the tag list
280 @transaction.atomic
280 @transaction.atomic
281 def refresh_tags(self):
281 def refresh_tags(self):
282 for tag in self.get_tags().all():
282 for tag in self.get_tags().all():
283 parents = tag.get_all_parents()
283 parents = tag.get_all_parents()
284 if len(parents) > 0:
284 if len(parents) > 0:
285 self.tags.add(*parents)
285 self.tags.add(*parents)
286
286
287 def get_reply_tree(self):
287 def get_reply_tree(self):
288 replies = self.get_replies().prefetch_related('refposts')
288 replies = self.get_replies().prefetch_related('refposts')
289 tree = []
289 tree = []
290 for reply in replies:
290 for reply in replies:
291 parents = reply.refposts.all()
291 parents = reply.refposts.all()
292
292
293 found_parent = False
293 found_parent = False
294 searching_for_index = False
294 searching_for_index = False
295
295
296 if len(parents) > 0:
296 if len(parents) > 0:
297 index = 0
297 index = 0
298 parent_depth = 0
298 parent_depth = 0
299
299
300 indexes_to_insert = []
300 indexes_to_insert = []
301
301
302 for depth, element in tree:
302 for depth, element in tree:
303 index += 1
303 index += 1
304
304
305 # If this element is next after parent on the same level,
305 # If this element is next after parent on the same level,
306 # insert child before it
306 # insert child before it
307 if searching_for_index and depth <= parent_depth:
307 if searching_for_index and depth <= parent_depth:
308 indexes_to_insert.append((index - 1, parent_depth))
308 indexes_to_insert.append((index - 1, parent_depth))
309 searching_for_index = False
309 searching_for_index = False
310
310
311 if element in parents:
311 if element in parents:
312 found_parent = True
312 found_parent = True
313 searching_for_index = True
313 searching_for_index = True
314 parent_depth = depth
314 parent_depth = depth
315
315
316 if not found_parent:
316 if not found_parent:
317 tree.append((0, reply))
317 tree.append((0, reply))
318 else:
318 else:
319 if searching_for_index:
319 if searching_for_index:
320 tree.append((parent_depth + 1, reply))
320 tree.append((parent_depth + 1, reply))
321
321
322 offset = 0
322 offset = 0
323 for last_index, parent_depth in indexes_to_insert:
323 for last_index, parent_depth in indexes_to_insert:
324 tree.insert(last_index + offset, (parent_depth + 1, reply))
324 tree.insert(last_index + offset, (parent_depth + 1, reply))
325 offset += 1
325 offset += 1
326
326
327 return tree
327 return tree
@@ -1,81 +1,81 b''
1 {% extends "boards/thread.html" %}
1 {% extends "boards/thread.html" %}
2
2
3 {% load i18n %}
3 {% load i18n %}
4 {% load static from staticfiles %}
4 {% load static from staticfiles %}
5 {% load board %}
5 {% load board %}
6 {% load tz %}
6 {% load tz %}
7
7
8 {% block thread_content %}
8 {% block thread_content %}
9 {% get_current_language as LANGUAGE_CODE %}
9 {% get_current_language as LANGUAGE_CODE %}
10 {% get_current_timezone as TIME_ZONE %}
10 {% get_current_timezone as TIME_ZONE %}
11
11
12 <div id="quote-button">{% trans 'Quote' %}</div>
12 <div id="quote-button">{% trans 'Quote' %}</div>
13
13
14 <div class="tag_info">
14 <div class="tag_info">
15 <h2>
15 <h2>
16 <form action="{% url 'thread' opening_post.id %}" method="post" class="post-button-form">
16 <form action="{% url 'thread' opening_post.id %}" method="post" class="post-button-form">
17 {% csrf_token %}
17 {% csrf_token %}
18 {% if is_favorite %}
18 {% if is_favorite %}
19 <button name="method" value="unsubscribe" class="fav">β˜…</button>
19 <button name="method" value="unsubscribe" class="fav">β˜…</button>
20 {% else %}
20 {% else %}
21 <button name="method" value="subscribe" class="not_fav">β˜…</button>
21 <button name="method" value="subscribe" class="not_fav">β˜…</button>
22 {% endif %}
22 {% endif %}
23 </form>
23 </form>
24 {{ opening_post.get_title_or_text }}
24 {{ opening_post.get_title_or_text }}
25 </h2>
25 </h2>
26 </div>
26 </div>
27
27
28 {% if bumpable and thread.has_post_limit %}
28 {% if bumpable and thread.has_post_limit %}
29 <div class="bar-bg">
29 <div class="bar-bg">
30 <div class="bar-value" style="width:{{ bumplimit_progress }}%" id="bumplimit_progress">
30 <div class="bar-value" style="width:{{ bumplimit_progress }}%" id="bumplimit_progress">
31 </div>
31 </div>
32 <div class="bar-text">
32 <div class="bar-text">
33 <span id="left_to_limit">{{ posts_left }}</span> {% trans 'posts to bumplimit' %}
33 <span id="left_to_limit">{{ posts_left }}</span> {% trans 'posts to bumplimit' %}
34 </div>
34 </div>
35 </div>
35 </div>
36 {% endif %}
36 {% endif %}
37
37
38 <div class="thread">
38 <div class="thread">
39 {% for post in thread.get_viewable_replies %}
39 {% for post in thread.get_viewable_replies %}
40 {% post_view post reply_link=True %}
40 {% post_view post reply_link=True thread=thread %}
41 {% endfor %}
41 {% endfor %}
42 </div>
42 </div>
43
43
44 {% if not thread.is_archived %}
44 {% if not thread.is_archived %}
45 <div class="post-form-w">
45 <div class="post-form-w">
46 <script src="{% static 'js/panel.js' %}"></script>
46 <script src="{% static 'js/panel.js' %}"></script>
47 <div class="form-title">{% trans "Reply to thread" %} #{{ opening_post.id }}<span class="reply-to-message"> {% trans "to message " %} #<span id="reply-to-message-id"></span></span></div>
47 <div class="form-title">{% trans "Reply to thread" %} #{{ opening_post.id }}<span class="reply-to-message"> {% trans "to message " %} #<span id="reply-to-message-id"></span></span></div>
48 <div class="post-form" id="compact-form" data-hasher="{% static 'js/3party/sha256.js' %}"
48 <div class="post-form" id="compact-form" data-hasher="{% static 'js/3party/sha256.js' %}"
49 data-pow-script="{% static 'js/proof_of_work.js' %}">
49 data-pow-script="{% static 'js/proof_of_work.js' %}">
50 <div class="swappable-form-full">
50 <div class="swappable-form-full">
51 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
51 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
52 <div class="compact-form-text"></div>
52 <div class="compact-form-text"></div>
53 {{ form.as_div }}
53 {{ form.as_div }}
54 <div class="form-submit">
54 <div class="form-submit">
55 <input type="submit" value="{% trans "Post" %}"/>
55 <input type="submit" value="{% trans "Post" %}"/>
56 <button id="preview-button" type="button" onclick="return false;">{% trans 'Preview' %}</button>
56 <button id="preview-button" type="button" onclick="return false;">{% trans 'Preview' %}</button>
57 <button id="file-source-button" type="button" onclick="return false;">{% trans 'Change file source' %}</button>
57 <button id="file-source-button" type="button" onclick="return false;">{% trans 'Change file source' %}</button>
58 </div>
58 </div>
59 </form>
59 </form>
60 </div>
60 </div>
61 <div id="preview-text"></div>
61 <div id="preview-text"></div>
62 <div>
62 <div>
63 {% with size=max_file_size|filesizeformat %}
63 {% with size=max_file_size|filesizeformat %}
64 {% blocktrans %}Max file size is {{ size }}.{% endblocktrans %}
64 {% blocktrans %}Max file size is {{ size }}.{% endblocktrans %}
65 {% endwith %}
65 {% endwith %}
66 </div>
66 </div>
67 <div><a href="{% url "staticpage" name="help" %}">
67 <div><a href="{% url "staticpage" name="help" %}">
68 {% trans 'Text syntax' %}</a></div>
68 {% trans 'Text syntax' %}</a></div>
69 <div><a id="form-close-button" href="#" onClick="resetFormPosition(); return false;">{% trans 'Close form' %}</a></div>
69 <div><a id="form-close-button" href="#" onClick="resetFormPosition(); return false;">{% trans 'Close form' %}</a></div>
70 </div>
70 </div>
71 </div>
71 </div>
72
72
73 <script src="{% static 'js/form.js' %}"></script>
73 <script src="{% static 'js/form.js' %}"></script>
74 <script src="{% static 'js/jquery.form.min.js' %}"></script>
74 <script src="{% static 'js/jquery.form.min.js' %}"></script>
75 <script src="{% static 'js/3party/jquery.blockUI.js' %}"></script>
75 <script src="{% static 'js/3party/jquery.blockUI.js' %}"></script>
76 <script src="{% static 'js/thread.js' %}"></script>
76 <script src="{% static 'js/thread.js' %}"></script>
77 <script src="{% static 'js/thread_update.js' %}"></script>
77 <script src="{% static 'js/thread_update.js' %}"></script>
78 {% endif %}
78 {% endif %}
79
79
80 <script src="{% static 'js/3party/centrifuge.js' %}"></script>
80 <script src="{% static 'js/3party/centrifuge.js' %}"></script>
81 {% endblock %}
81 {% endblock %}
@@ -1,186 +1,186 b''
1 from django.core.urlresolvers import reverse
1 from django.core.urlresolvers import reverse
2 from django.core.files import File
2 from django.core.files import File
3 from django.core.files.temp import NamedTemporaryFile
3 from django.core.files.temp import NamedTemporaryFile
4 from django.core.paginator import EmptyPage
4 from django.core.paginator import EmptyPage
5 from django.db import transaction
5 from django.db import transaction
6 from django.http import Http404
6 from django.http import Http404
7 from django.shortcuts import render, redirect
7 from django.shortcuts import render, redirect
8 from django.utils.decorators import method_decorator
8 from django.utils.decorators import method_decorator
9 from django.views.decorators.csrf import csrf_protect
9 from django.views.decorators.csrf import csrf_protect
10
10
11 from boards import utils, settings
11 from boards import utils, settings
12 from boards.abstracts.paginator import get_paginator
12 from boards.abstracts.paginator import get_paginator
13 from boards.abstracts.settingsmanager import get_settings_manager,\
13 from boards.abstracts.settingsmanager import get_settings_manager,\
14 SETTING_ONLY_FAVORITES
14 SETTING_ONLY_FAVORITES
15 from boards.forms import ThreadForm, PlainErrorList
15 from boards.forms import ThreadForm, PlainErrorList
16 from boards.models import Post, Thread, Ban
16 from boards.models import Post, Thread, Ban
17 from boards.views.banned import BannedView
17 from boards.views.banned import BannedView
18 from boards.views.base import BaseBoardView, CONTEXT_FORM
18 from boards.views.base import BaseBoardView, CONTEXT_FORM
19 from boards.views.posting_mixin import PostMixin
19 from boards.views.posting_mixin import PostMixin
20 from boards.views.mixins import FileUploadMixin, PaginatedMixin,\
20 from boards.views.mixins import FileUploadMixin, PaginatedMixin,\
21 DispatcherMixin, PARAMETER_METHOD
21 DispatcherMixin, PARAMETER_METHOD
22
22
23 FORM_TAGS = 'tags'
23 FORM_TAGS = 'tags'
24 FORM_TEXT = 'text'
24 FORM_TEXT = 'text'
25 FORM_TITLE = 'title'
25 FORM_TITLE = 'title'
26 FORM_IMAGE = 'image'
26 FORM_IMAGE = 'image'
27 FORM_THREADS = 'threads'
27 FORM_THREADS = 'threads'
28
28
29 TAG_DELIMITER = ' '
29 TAG_DELIMITER = ' '
30
30
31 PARAMETER_CURRENT_PAGE = 'current_page'
31 PARAMETER_CURRENT_PAGE = 'current_page'
32 PARAMETER_PAGINATOR = 'paginator'
32 PARAMETER_PAGINATOR = 'paginator'
33 PARAMETER_THREADS = 'threads'
33 PARAMETER_THREADS = 'threads'
34 PARAMETER_ADDITIONAL = 'additional_params'
34 PARAMETER_ADDITIONAL = 'additional_params'
35 PARAMETER_MAX_FILE_SIZE = 'max_file_size'
35 PARAMETER_MAX_FILE_SIZE = 'max_file_size'
36 PARAMETER_RSS_URL = 'rss_url'
36 PARAMETER_RSS_URL = 'rss_url'
37
37
38 TEMPLATE = 'boards/all_threads.html'
38 TEMPLATE = 'boards/all_threads.html'
39 DEFAULT_PAGE = 1
39 DEFAULT_PAGE = 1
40
40
41
41
42 class AllThreadsView(PostMixin, FileUploadMixin, BaseBoardView, PaginatedMixin, DispatcherMixin):
42 class AllThreadsView(PostMixin, FileUploadMixin, BaseBoardView, PaginatedMixin, DispatcherMixin):
43
43
44 def __init__(self):
44 def __init__(self):
45 self.settings_manager = None
45 self.settings_manager = None
46 super(AllThreadsView, self).__init__()
46 super(AllThreadsView, self).__init__()
47
47
48 @method_decorator(csrf_protect)
48 @method_decorator(csrf_protect)
49 def get(self, request, form: ThreadForm=None):
49 def get(self, request, form: ThreadForm=None):
50 page = request.GET.get('page', DEFAULT_PAGE)
50 page = request.GET.get('page', DEFAULT_PAGE)
51
51
52 params = self.get_context_data(request=request)
52 params = self.get_context_data(request=request)
53
53
54 if not form:
54 if not form:
55 form = ThreadForm(error_class=PlainErrorList)
55 form = ThreadForm(error_class=PlainErrorList)
56
56
57 self.settings_manager = get_settings_manager(request)
57 self.settings_manager = get_settings_manager(request)
58
58
59 threads = self.get_threads()
59 threads = self.get_threads()
60
60
61 order = request.GET.get('order', 'bump')
61 order = request.GET.get('order', 'bump')
62 if order == 'bump':
62 if order == 'bump':
63 threads = threads.order_by('-bump_time')
63 threads = threads.order_by('-bump_time')
64 else:
64 else:
65 threads = threads.filter(multi_replies__opening=True).order_by('-multi_replies__pub_time')
65 threads = threads.filter(replies__opening=True)\
66 .order_by('-replies__pub_time')
66 filter = request.GET.get('filter')
67 filter = request.GET.get('filter')
67 threads = threads.distinct()
68 threads = threads.distinct()
68
69
69 paginator = get_paginator(threads,
70 paginator = get_paginator(threads,
70 settings.get_int('View', 'ThreadsPerPage'))
71 settings.get_int('View', 'ThreadsPerPage'))
71 paginator.current_page = int(page)
72 paginator.current_page = int(page)
72
73
73 try:
74 try:
74 threads = paginator.page(page).object_list
75 threads = paginator.page(page).object_list
75 except EmptyPage:
76 except EmptyPage:
76 raise Http404()
77 raise Http404()
77
78
78 params[PARAMETER_THREADS] = threads
79 params[PARAMETER_THREADS] = threads
79 params[CONTEXT_FORM] = form
80 params[CONTEXT_FORM] = form
80 params[PARAMETER_MAX_FILE_SIZE] = self.get_max_upload_size()
81 params[PARAMETER_MAX_FILE_SIZE] = self.get_max_upload_size()
81 params[PARAMETER_RSS_URL] = self.get_rss_url()
82 params[PARAMETER_RSS_URL] = self.get_rss_url()
82
83
83 paginator.set_url(self.get_reverse_url(), request.GET.dict())
84 paginator.set_url(self.get_reverse_url(), request.GET.dict())
84 self.get_page_context(paginator, params, page)
85 self.get_page_context(paginator, params, page)
85
86
86 return render(request, TEMPLATE, params)
87 return render(request, TEMPLATE, params)
87
88
88 @method_decorator(csrf_protect)
89 @method_decorator(csrf_protect)
89 def post(self, request):
90 def post(self, request):
90 if PARAMETER_METHOD in request.POST:
91 if PARAMETER_METHOD in request.POST:
91 self.dispatch_method(request)
92 self.dispatch_method(request)
92
93
93 return redirect('index') # FIXME Different for different modes
94 return redirect('index') # FIXME Different for different modes
94
95
95 form = ThreadForm(request.POST, request.FILES,
96 form = ThreadForm(request.POST, request.FILES,
96 error_class=PlainErrorList)
97 error_class=PlainErrorList)
97 form.session = request.session
98 form.session = request.session
98
99
99 if form.is_valid():
100 if form.is_valid():
100 return self.create_thread(request, form)
101 return self.create_thread(request, form)
101 if form.need_to_ban:
102 if form.need_to_ban:
102 # Ban user because he is suspected to be a bot
103 # Ban user because he is suspected to be a bot
103 self._ban_current_user(request)
104 self._ban_current_user(request)
104
105
105 return self.get(request, form)
106 return self.get(request, form)
106
107
107 def get_page_context(self, paginator, params, page):
108 def get_page_context(self, paginator, params, page):
108 """
109 """
109 Get pagination context variables
110 Get pagination context variables
110 """
111 """
111
112
112 params[PARAMETER_PAGINATOR] = paginator
113 params[PARAMETER_PAGINATOR] = paginator
113 current_page = paginator.page(int(page))
114 current_page = paginator.page(int(page))
114 params[PARAMETER_CURRENT_PAGE] = current_page
115 params[PARAMETER_CURRENT_PAGE] = current_page
115 self.set_page_urls(paginator, params)
116 self.set_page_urls(paginator, params)
116
117
117 def get_reverse_url(self):
118 def get_reverse_url(self):
118 return reverse('index')
119 return reverse('index')
119
120
120 @transaction.atomic
121 @transaction.atomic
121 def create_thread(self, request, form: ThreadForm, html_response=True):
122 def create_thread(self, request, form: ThreadForm, html_response=True):
122 """
123 """
123 Creates a new thread with an opening post.
124 Creates a new thread with an opening post.
124 """
125 """
125
126
126 ip = utils.get_client_ip(request)
127 ip = utils.get_client_ip(request)
127 is_banned = Ban.objects.filter(ip=ip).exists()
128 is_banned = Ban.objects.filter(ip=ip).exists()
128
129
129 if is_banned:
130 if is_banned:
130 if html_response:
131 if html_response:
131 return redirect(BannedView().as_view())
132 return redirect(BannedView().as_view())
132 else:
133 else:
133 return
134 return
134
135
135 data = form.cleaned_data
136 data = form.cleaned_data
136
137
137 title = form.get_title()
138 title = form.get_title()
138 text = data[FORM_TEXT]
139 text = data[FORM_TEXT]
139 file = form.get_file()
140 file = form.get_file()
140 file_url = form.get_file_url()
141 file_url = form.get_file_url()
141 threads = data[FORM_THREADS]
142 images = form.get_images()
142 images = form.get_images()
143
143
144 text = self._remove_invalid_links(text)
144 text = self._remove_invalid_links(text)
145
145
146 tags = data[FORM_TAGS]
146 tags = data[FORM_TAGS]
147 monochrome = form.is_monochrome()
147 monochrome = form.is_monochrome()
148
148
149 post = Post.objects.create_post(title=title, text=text, file=file,
149 post = Post.objects.create_post(title=title, text=text, file=file,
150 ip=ip, tags=tags, opening_posts=threads,
150 ip=ip, tags=tags,
151 tripcode=form.get_tripcode(),
151 tripcode=form.get_tripcode(),
152 monochrome=monochrome, images=images,
152 monochrome=monochrome, images=images,
153 file_url = file_url)
153 file_url = file_url)
154
154
155 # This is required to update the threads to which posts we have replied
155 # This is required to update the threads to which posts we have replied
156 # when creating this one
156 # when creating this one
157 post.notify_clients()
157 post.notify_clients()
158
158
159 if form.is_subscribe():
159 if form.is_subscribe():
160 settings_manager = get_settings_manager(request)
160 settings_manager = get_settings_manager(request)
161 settings_manager.add_or_read_fav_thread(post)
161 settings_manager.add_or_read_fav_thread(post)
162
162
163 if html_response:
163 if html_response:
164 return redirect(post.get_absolute_url())
164 return redirect(post.get_absolute_url())
165
165
166 def get_threads(self):
166 def get_threads(self):
167 """
167 """
168 Gets list of threads that will be shown on a page.
168 Gets list of threads that will be shown on a page.
169 """
169 """
170
170
171 threads = Thread.objects\
171 threads = Thread.objects\
172 .exclude(tags__in=self.settings_manager.get_hidden_tags())
172 .exclude(tags__in=self.settings_manager.get_hidden_tags())
173 if self.settings_manager.get_setting(SETTING_ONLY_FAVORITES):
173 if self.settings_manager.get_setting(SETTING_ONLY_FAVORITES):
174 fav_tags = self.settings_manager.get_fav_tags()
174 fav_tags = self.settings_manager.get_fav_tags()
175 if len(fav_tags) > 0:
175 if len(fav_tags) > 0:
176 threads = threads.filter(tags__in=fav_tags)
176 threads = threads.filter(tags__in=fav_tags)
177
177
178 return threads
178 return threads
179
179
180 def get_rss_url(self):
180 def get_rss_url(self):
181 return self.get_reverse_url() + 'rss/'
181 return self.get_reverse_url() + 'rss/'
182
182
183 def toggle_fav(self, request):
183 def toggle_fav(self, request):
184 settings_manager = get_settings_manager(request)
184 settings_manager = get_settings_manager(request)
185 settings_manager.set_setting(SETTING_ONLY_FAVORITES,
185 settings_manager.set_setting(SETTING_ONLY_FAVORITES,
186 not settings_manager.get_setting(SETTING_ONLY_FAVORITES, False))
186 not settings_manager.get_setting(SETTING_ONLY_FAVORITES, False))
@@ -1,315 +1,315 b''
1 import json
1 import json
2 import logging
2 import logging
3
3
4 from django.core import serializers
4 from django.core import serializers
5 from django.db import transaction
5 from django.db import transaction
6 from django.http import HttpResponse
6 from django.http import HttpResponse
7 from django.shortcuts import get_object_or_404
7 from django.shortcuts import get_object_or_404
8 from django.views.decorators.csrf import csrf_protect
8 from django.views.decorators.csrf import csrf_protect
9
9
10 from boards.abstracts.settingsmanager import get_settings_manager
10 from boards.abstracts.settingsmanager import get_settings_manager
11 from boards.forms import PostForm, PlainErrorList
11 from boards.forms import PostForm, PlainErrorList
12 from boards.mdx_neboard import Parser
12 from boards.mdx_neboard import Parser
13 from boards.models import Post, Thread, Tag, Attachment
13 from boards.models import Post, Thread, Tag, Attachment
14 from boards.models.thread import STATUS_ARCHIVE
14 from boards.models.thread import STATUS_ARCHIVE
15 from boards.models.user import Notification
15 from boards.models.user import Notification
16 from boards.utils import datetime_to_epoch
16 from boards.utils import datetime_to_epoch
17 from boards.views.thread import ThreadView
17 from boards.views.thread import ThreadView
18 from boards.models.attachment.viewers import FILE_TYPES_IMAGE
18 from boards.models.attachment.viewers import FILE_TYPES_IMAGE
19
19
20 __author__ = 'neko259'
20 __author__ = 'neko259'
21
21
22 PARAMETER_TRUNCATED = 'truncated'
22 PARAMETER_TRUNCATED = 'truncated'
23 PARAMETER_TAG = 'tag'
23 PARAMETER_TAG = 'tag'
24 PARAMETER_OFFSET = 'offset'
24 PARAMETER_OFFSET = 'offset'
25 PARAMETER_DIFF_TYPE = 'type'
25 PARAMETER_DIFF_TYPE = 'type'
26 PARAMETER_POST = 'post'
26 PARAMETER_POST = 'post'
27 PARAMETER_UPDATED = 'updated'
27 PARAMETER_UPDATED = 'updated'
28 PARAMETER_LAST_UPDATE = 'last_update'
28 PARAMETER_LAST_UPDATE = 'last_update'
29 PARAMETER_THREAD = 'thread'
29 PARAMETER_THREAD = 'thread'
30 PARAMETER_UIDS = 'uids'
30 PARAMETER_UIDS = 'uids'
31 PARAMETER_SUBSCRIBED = 'subscribed'
31 PARAMETER_SUBSCRIBED = 'subscribed'
32
32
33 DIFF_TYPE_HTML = 'html'
33 DIFF_TYPE_HTML = 'html'
34 DIFF_TYPE_JSON = 'json'
34 DIFF_TYPE_JSON = 'json'
35
35
36 STATUS_OK = 'ok'
36 STATUS_OK = 'ok'
37 STATUS_ERROR = 'error'
37 STATUS_ERROR = 'error'
38
38
39 logger = logging.getLogger(__name__)
39 logger = logging.getLogger(__name__)
40
40
41
41
42 @transaction.atomic
42 @transaction.atomic
43 def api_get_threaddiff(request):
43 def api_get_threaddiff(request):
44 """
44 """
45 Gets posts that were changed or added since time
45 Gets posts that were changed or added since time
46 """
46 """
47
47
48 thread_id = request.POST.get(PARAMETER_THREAD)
48 thread_id = request.POST.get(PARAMETER_THREAD)
49 uids_str = request.POST.get(PARAMETER_UIDS)
49 uids_str = request.POST.get(PARAMETER_UIDS)
50
50
51 if not thread_id or not uids_str:
51 if not thread_id or not uids_str:
52 return HttpResponse(content='Invalid request.')
52 return HttpResponse(content='Invalid request.')
53
53
54 uids = uids_str.strip().split(' ')
54 uids = uids_str.strip().split(' ')
55
55
56 opening_post = get_object_or_404(Post, id=thread_id)
56 opening_post = get_object_or_404(Post, id=thread_id)
57 thread = opening_post.get_thread()
57 thread = opening_post.get_thread()
58
58
59 json_data = {
59 json_data = {
60 PARAMETER_UPDATED: [],
60 PARAMETER_UPDATED: [],
61 PARAMETER_LAST_UPDATE: None, # TODO Maybe this can be removed already?
61 PARAMETER_LAST_UPDATE: None, # TODO Maybe this can be removed already?
62 }
62 }
63 posts = Post.objects.filter(threads__in=[thread]).exclude(uid__in=uids)
63 posts = Post.objects.filter(thread=thread).exclude(uid__in=uids)
64
64
65 diff_type = request.GET.get(PARAMETER_DIFF_TYPE, DIFF_TYPE_HTML)
65 diff_type = request.GET.get(PARAMETER_DIFF_TYPE, DIFF_TYPE_HTML)
66
66
67 for post in posts:
67 for post in posts:
68 json_data[PARAMETER_UPDATED].append(post.get_post_data(
68 json_data[PARAMETER_UPDATED].append(post.get_post_data(
69 format_type=diff_type, request=request))
69 format_type=diff_type, request=request))
70 json_data[PARAMETER_LAST_UPDATE] = str(thread.last_edit_time)
70 json_data[PARAMETER_LAST_UPDATE] = str(thread.last_edit_time)
71
71
72 settings_manager = get_settings_manager(request)
72 settings_manager = get_settings_manager(request)
73 json_data[PARAMETER_SUBSCRIBED] = str(settings_manager.thread_is_fav(opening_post))
73 json_data[PARAMETER_SUBSCRIBED] = str(settings_manager.thread_is_fav(opening_post))
74
74
75 # If the tag is favorite, update the counter
75 # If the tag is favorite, update the counter
76 settings_manager = get_settings_manager(request)
76 settings_manager = get_settings_manager(request)
77 favorite = settings_manager.thread_is_fav(opening_post)
77 favorite = settings_manager.thread_is_fav(opening_post)
78 if favorite:
78 if favorite:
79 settings_manager.add_or_read_fav_thread(opening_post)
79 settings_manager.add_or_read_fav_thread(opening_post)
80
80
81 return HttpResponse(content=json.dumps(json_data))
81 return HttpResponse(content=json.dumps(json_data))
82
82
83
83
84 @csrf_protect
84 @csrf_protect
85 def api_add_post(request, opening_post_id):
85 def api_add_post(request, opening_post_id):
86 """
86 """
87 Adds a post and return the JSON response for it
87 Adds a post and return the JSON response for it
88 """
88 """
89
89
90 opening_post = get_object_or_404(Post, id=opening_post_id)
90 opening_post = get_object_or_404(Post, id=opening_post_id)
91
91
92 logger.info('Adding post via api...')
92 logger.info('Adding post via api...')
93
93
94 status = STATUS_OK
94 status = STATUS_OK
95 errors = []
95 errors = []
96
96
97 if request.method == 'POST':
97 if request.method == 'POST':
98 form = PostForm(request.POST, request.FILES, error_class=PlainErrorList)
98 form = PostForm(request.POST, request.FILES, error_class=PlainErrorList)
99 form.session = request.session
99 form.session = request.session
100
100
101 if form.need_to_ban:
101 if form.need_to_ban:
102 # Ban user because he is suspected to be a bot
102 # Ban user because he is suspected to be a bot
103 # _ban_current_user(request)
103 # _ban_current_user(request)
104 status = STATUS_ERROR
104 status = STATUS_ERROR
105 if form.is_valid():
105 if form.is_valid():
106 post = ThreadView().new_post(request, form, opening_post,
106 post = ThreadView().new_post(request, form, opening_post,
107 html_response=False)
107 html_response=False)
108 if not post:
108 if not post:
109 status = STATUS_ERROR
109 status = STATUS_ERROR
110 else:
110 else:
111 logger.info('Added post #%d via api.' % post.id)
111 logger.info('Added post #%d via api.' % post.id)
112 else:
112 else:
113 status = STATUS_ERROR
113 status = STATUS_ERROR
114 errors = form.as_json_errors()
114 errors = form.as_json_errors()
115
115
116 response = {
116 response = {
117 'status': status,
117 'status': status,
118 'errors': errors,
118 'errors': errors,
119 }
119 }
120
120
121 return HttpResponse(content=json.dumps(response))
121 return HttpResponse(content=json.dumps(response))
122
122
123
123
124 def get_post(request, post_id):
124 def get_post(request, post_id):
125 """
125 """
126 Gets the html of a post. Used for popups. Post can be truncated if used
126 Gets the html of a post. Used for popups. Post can be truncated if used
127 in threads list with 'truncated' get parameter.
127 in threads list with 'truncated' get parameter.
128 """
128 """
129
129
130 post = get_object_or_404(Post, id=post_id)
130 post = get_object_or_404(Post, id=post_id)
131 truncated = PARAMETER_TRUNCATED in request.GET
131 truncated = PARAMETER_TRUNCATED in request.GET
132
132
133 return HttpResponse(content=post.get_view(truncated=truncated, need_op_data=True))
133 return HttpResponse(content=post.get_view(truncated=truncated, need_op_data=True))
134
134
135
135
136 def api_get_threads(request, count):
136 def api_get_threads(request, count):
137 """
137 """
138 Gets the JSON thread opening posts list.
138 Gets the JSON thread opening posts list.
139 Parameters that can be used for filtering:
139 Parameters that can be used for filtering:
140 tag, offset (from which thread to get results)
140 tag, offset (from which thread to get results)
141 """
141 """
142
142
143 if PARAMETER_TAG in request.GET:
143 if PARAMETER_TAG in request.GET:
144 tag_name = request.GET[PARAMETER_TAG]
144 tag_name = request.GET[PARAMETER_TAG]
145 if tag_name is not None:
145 if tag_name is not None:
146 tag = get_object_or_404(Tag, name=tag_name)
146 tag = get_object_or_404(Tag, name=tag_name)
147 threads = tag.get_threads().exclude(status=STATUS_ARCHIVE)
147 threads = tag.get_threads().exclude(status=STATUS_ARCHIVE)
148 else:
148 else:
149 threads = Thread.objects.exclude(status=STATUS_ARCHIVE)
149 threads = Thread.objects.exclude(status=STATUS_ARCHIVE)
150
150
151 if PARAMETER_OFFSET in request.GET:
151 if PARAMETER_OFFSET in request.GET:
152 offset = request.GET[PARAMETER_OFFSET]
152 offset = request.GET[PARAMETER_OFFSET]
153 offset = int(offset) if offset is not None else 0
153 offset = int(offset) if offset is not None else 0
154 else:
154 else:
155 offset = 0
155 offset = 0
156
156
157 threads = threads.order_by('-bump_time')
157 threads = threads.order_by('-bump_time')
158 threads = threads[offset:offset + int(count)]
158 threads = threads[offset:offset + int(count)]
159
159
160 opening_posts = []
160 opening_posts = []
161 for thread in threads:
161 for thread in threads:
162 opening_post = thread.get_opening_post()
162 opening_post = thread.get_opening_post()
163
163
164 # TODO Add tags, replies and images count
164 # TODO Add tags, replies and images count
165 post_data = opening_post.get_post_data(include_last_update=True)
165 post_data = opening_post.get_post_data(include_last_update=True)
166 post_data['status'] = thread.get_status()
166 post_data['status'] = thread.get_status()
167
167
168 opening_posts.append(post_data)
168 opening_posts.append(post_data)
169
169
170 return HttpResponse(content=json.dumps(opening_posts))
170 return HttpResponse(content=json.dumps(opening_posts))
171
171
172
172
173 # TODO Test this
173 # TODO Test this
174 def api_get_tags(request):
174 def api_get_tags(request):
175 """
175 """
176 Gets all tags or user tags.
176 Gets all tags or user tags.
177 """
177 """
178
178
179 # TODO Get favorite tags for the given user ID
179 # TODO Get favorite tags for the given user ID
180
180
181 tags = Tag.objects.get_not_empty_tags()
181 tags = Tag.objects.get_not_empty_tags()
182
182
183 term = request.GET.get('term')
183 term = request.GET.get('term')
184 if term is not None:
184 if term is not None:
185 tags = tags.filter(name__contains=term)
185 tags = tags.filter(name__contains=term)
186
186
187 tag_names = [tag.name for tag in tags]
187 tag_names = [tag.name for tag in tags]
188
188
189 return HttpResponse(content=json.dumps(tag_names))
189 return HttpResponse(content=json.dumps(tag_names))
190
190
191
191
192 def api_get_stickers(request):
192 def api_get_stickers(request):
193 attachments = Attachment.objects.filter(mimetype__in=FILE_TYPES_IMAGE)\
193 attachments = Attachment.objects.filter(mimetype__in=FILE_TYPES_IMAGE)\
194 .exclude(alias='').exclude(alias=None)
194 .exclude(alias='').exclude(alias=None)
195
195
196 term = request.GET.get('term')
196 term = request.GET.get('term')
197 if term:
197 if term:
198 attachments = attachments.filter(alias__contains=term)
198 attachments = attachments.filter(alias__contains=term)
199
199
200 image_dict = [{'thumb': attachment.get_thumb_url(),
200 image_dict = [{'thumb': attachment.get_thumb_url(),
201 'alias': attachment.alias}
201 'alias': attachment.alias}
202 for attachment in attachments]
202 for attachment in attachments]
203
203
204 return HttpResponse(content=json.dumps(image_dict))
204 return HttpResponse(content=json.dumps(image_dict))
205
205
206
206
207 # TODO The result can be cached by the thread last update time
207 # TODO The result can be cached by the thread last update time
208 # TODO Test this
208 # TODO Test this
209 def api_get_thread_posts(request, opening_post_id):
209 def api_get_thread_posts(request, opening_post_id):
210 """
210 """
211 Gets the JSON array of thread posts
211 Gets the JSON array of thread posts
212 """
212 """
213
213
214 opening_post = get_object_or_404(Post, id=opening_post_id)
214 opening_post = get_object_or_404(Post, id=opening_post_id)
215 thread = opening_post.get_thread()
215 thread = opening_post.get_thread()
216 posts = thread.get_replies()
216 posts = thread.get_replies()
217
217
218 json_data = {
218 json_data = {
219 'posts': [],
219 'posts': [],
220 'last_update': None,
220 'last_update': None,
221 }
221 }
222 json_post_list = []
222 json_post_list = []
223
223
224 for post in posts:
224 for post in posts:
225 json_post_list.append(post.get_post_data())
225 json_post_list.append(post.get_post_data())
226 json_data['last_update'] = datetime_to_epoch(thread.last_edit_time)
226 json_data['last_update'] = datetime_to_epoch(thread.last_edit_time)
227 json_data['posts'] = json_post_list
227 json_data['posts'] = json_post_list
228
228
229 return HttpResponse(content=json.dumps(json_data))
229 return HttpResponse(content=json.dumps(json_data))
230
230
231
231
232 def api_get_notifications(request, username):
232 def api_get_notifications(request, username):
233 last_notification_id_str = request.GET.get('last', None)
233 last_notification_id_str = request.GET.get('last', None)
234 last_id = int(last_notification_id_str) if last_notification_id_str is not None else None
234 last_id = int(last_notification_id_str) if last_notification_id_str is not None else None
235
235
236 posts = Notification.objects.get_notification_posts(usernames=[username],
236 posts = Notification.objects.get_notification_posts(usernames=[username],
237 last=last_id)
237 last=last_id)
238
238
239 json_post_list = []
239 json_post_list = []
240 for post in posts:
240 for post in posts:
241 json_post_list.append(post.get_post_data())
241 json_post_list.append(post.get_post_data())
242 return HttpResponse(content=json.dumps(json_post_list))
242 return HttpResponse(content=json.dumps(json_post_list))
243
243
244
244
245 def api_get_post(request, post_id):
245 def api_get_post(request, post_id):
246 """
246 """
247 Gets the JSON of a post. This can be
247 Gets the JSON of a post. This can be
248 used as and API for external clients.
248 used as and API for external clients.
249 """
249 """
250
250
251 post = get_object_or_404(Post, id=post_id)
251 post = get_object_or_404(Post, id=post_id)
252
252
253 json = serializers.serialize("json", [post], fields=(
253 json = serializers.serialize("json", [post], fields=(
254 "pub_time", "_text_rendered", "title", "text", "image",
254 "pub_time", "_text_rendered", "title", "text", "image",
255 "image_width", "image_height", "replies", "tags"
255 "image_width", "image_height", "replies", "tags"
256 ))
256 ))
257
257
258 return HttpResponse(content=json)
258 return HttpResponse(content=json)
259
259
260
260
261 def api_get_preview(request):
261 def api_get_preview(request):
262 raw_text = request.POST['raw_text']
262 raw_text = request.POST['raw_text']
263
263
264 parser = Parser()
264 parser = Parser()
265 return HttpResponse(content=parser.parse(parser.preparse(raw_text)))
265 return HttpResponse(content=parser.parse(parser.preparse(raw_text)))
266
266
267
267
268 def api_get_new_posts(request):
268 def api_get_new_posts(request):
269 """
269 """
270 Gets favorite threads and unread posts count.
270 Gets favorite threads and unread posts count.
271 """
271 """
272 posts = list()
272 posts = list()
273
273
274 include_posts = 'include_posts' in request.GET
274 include_posts = 'include_posts' in request.GET
275
275
276 settings_manager = get_settings_manager(request)
276 settings_manager = get_settings_manager(request)
277 fav_threads = settings_manager.get_fav_threads()
277 fav_threads = settings_manager.get_fav_threads()
278 fav_thread_ops = Post.objects.filter(id__in=fav_threads.keys())\
278 fav_thread_ops = Post.objects.filter(id__in=fav_threads.keys())\
279 .order_by('-pub_time').prefetch_related('thread')
279 .order_by('-pub_time').prefetch_related('thread')
280
280
281 ops = [{'op': op, 'last_id': fav_threads[str(op.id)]} for op in fav_thread_ops]
281 ops = [{'op': op, 'last_id': fav_threads[str(op.id)]} for op in fav_thread_ops]
282 if include_posts:
282 if include_posts:
283 new_post_threads = Thread.objects.get_new_posts(ops)
283 new_post_threads = Thread.objects.get_new_posts(ops)
284 if new_post_threads:
284 if new_post_threads:
285 thread_ids = {thread.id: thread for thread in new_post_threads}
285 thread_ids = {thread.id: thread for thread in new_post_threads}
286 else:
286 else:
287 thread_ids = dict()
287 thread_ids = dict()
288
288
289 for op in fav_thread_ops:
289 for op in fav_thread_ops:
290 fav_thread_dict = dict()
290 fav_thread_dict = dict()
291
291
292 op_thread = op.get_thread()
292 op_thread = op.get_thread()
293 if op_thread.id in thread_ids:
293 if op_thread.id in thread_ids:
294 thread = thread_ids[op_thread.id]
294 thread = thread_ids[op_thread.id]
295 new_post_count = thread.new_post_count
295 new_post_count = thread.new_post_count
296 fav_thread_dict['newest_post_link'] = thread.get_replies()\
296 fav_thread_dict['newest_post_link'] = thread.get_replies()\
297 .filter(id__gt=fav_threads[str(op.id)])\
297 .filter(id__gt=fav_threads[str(op.id)])\
298 .first().get_absolute_url(thread=thread)
298 .first().get_absolute_url(thread=thread)
299 else:
299 else:
300 new_post_count = 0
300 new_post_count = 0
301 fav_thread_dict['new_post_count'] = new_post_count
301 fav_thread_dict['new_post_count'] = new_post_count
302
302
303 fav_thread_dict['id'] = op.id
303 fav_thread_dict['id'] = op.id
304
304
305 fav_thread_dict['post_url'] = op.get_link_view()
305 fav_thread_dict['post_url'] = op.get_link_view()
306 fav_thread_dict['title'] = op.title
306 fav_thread_dict['title'] = op.title
307
307
308 posts.append(fav_thread_dict)
308 posts.append(fav_thread_dict)
309 else:
309 else:
310 fav_thread_dict = dict()
310 fav_thread_dict = dict()
311 fav_thread_dict['new_post_count'] = \
311 fav_thread_dict['new_post_count'] = \
312 Thread.objects.get_new_post_count(ops)
312 Thread.objects.get_new_post_count(ops)
313 posts.append(fav_thread_dict)
313 posts.append(fav_thread_dict)
314
314
315 return HttpResponse(content=json.dumps(posts))
315 return HttpResponse(content=json.dumps(posts))
@@ -1,74 +1,74 b''
1 from django.core.urlresolvers import reverse
1 from django.core.urlresolvers import reverse
2 from django.shortcuts import render
2 from django.shortcuts import render
3
3
4 from boards import settings
4 from boards import settings
5 from boards.abstracts.paginator import get_paginator
5 from boards.abstracts.paginator import get_paginator
6 from boards.abstracts.settingsmanager import get_settings_manager
6 from boards.abstracts.settingsmanager import get_settings_manager
7 from boards.models import Post
7 from boards.models import Post
8 from boards.views.base import BaseBoardView
8 from boards.views.base import BaseBoardView
9 from boards.views.posting_mixin import PostMixin
9 from boards.views.posting_mixin import PostMixin
10
10
11 POSTS_PER_PAGE = settings.get_int('View', 'PostsPerPage')
11 POSTS_PER_PAGE = settings.get_int('View', 'PostsPerPage')
12
12
13 PARAMETER_CURRENT_PAGE = 'current_page'
13 PARAMETER_CURRENT_PAGE = 'current_page'
14 PARAMETER_PAGINATOR = 'paginator'
14 PARAMETER_PAGINATOR = 'paginator'
15 PARAMETER_POSTS = 'posts'
15 PARAMETER_POSTS = 'posts'
16
16
17 PARAMETER_PREV_LINK = 'prev_page_link'
17 PARAMETER_PREV_LINK = 'prev_page_link'
18 PARAMETER_NEXT_LINK = 'next_page_link'
18 PARAMETER_NEXT_LINK = 'next_page_link'
19
19
20 TEMPLATE = 'boards/feed.html'
20 TEMPLATE = 'boards/feed.html'
21 DEFAULT_PAGE = 1
21 DEFAULT_PAGE = 1
22
22
23
23
24 class FeedView(PostMixin, BaseBoardView):
24 class FeedView(PostMixin, BaseBoardView):
25
25
26 def get(self, request):
26 def get(self, request):
27 page = request.GET.get('page', DEFAULT_PAGE)
27 page = request.GET.get('page', DEFAULT_PAGE)
28 tripcode = request.GET.get('tripcode', None)
28 tripcode = request.GET.get('tripcode', None)
29 favorites = 'favorites' in request.GET
29 favorites = 'favorites' in request.GET
30 ip = request.GET.get('ip', None)
30 ip = request.GET.get('ip', None)
31
31
32 params = self.get_context_data(request=request)
32 params = self.get_context_data(request=request)
33
33
34 settings_manager = get_settings_manager(request)
34 settings_manager = get_settings_manager(request)
35
35
36 posts = Post.objects.exclude(
36 posts = Post.objects.exclude(
37 threads__tags__in=settings_manager.get_hidden_tags()).order_by(
37 thread__tags__in=settings_manager.get_hidden_tags()).order_by(
38 '-pub_time').prefetch_related('attachments', 'thread', 'threads')
38 '-pub_time').prefetch_related('attachments', 'thread')
39 if tripcode:
39 if tripcode:
40 posts = posts.filter(tripcode=tripcode)
40 posts = posts.filter(tripcode=tripcode)
41 if favorites:
41 if favorites:
42 fav_thread_ops = Post.objects.filter(id__in=settings_manager.get_fav_threads().keys())
42 fav_thread_ops = Post.objects.filter(id__in=settings_manager.get_fav_threads().keys())
43 fav_threads = [op.get_thread() for op in fav_thread_ops]
43 fav_threads = [op.get_thread() for op in fav_thread_ops]
44 posts = posts.filter(threads__in=fav_threads)
44 posts = posts.filter(threads__in=fav_threads)
45 if ip and request.user.has_perm('post_delete'):
45 if ip and request.user.has_perm('post_delete'):
46 posts = posts.filter(poster_ip=ip)
46 posts = posts.filter(poster_ip=ip)
47
47
48 paginator = get_paginator(posts, POSTS_PER_PAGE)
48 paginator = get_paginator(posts, POSTS_PER_PAGE)
49 paginator.current_page = int(page)
49 paginator.current_page = int(page)
50
50
51 params[PARAMETER_POSTS] = paginator.page(page).object_list
51 params[PARAMETER_POSTS] = paginator.page(page).object_list
52
52
53 paginator.set_url(reverse('feed'), request.GET.dict())
53 paginator.set_url(reverse('feed'), request.GET.dict())
54
54
55 self.get_page_context(paginator, params, page)
55 self.get_page_context(paginator, params, page)
56
56
57 return render(request, TEMPLATE, params)
57 return render(request, TEMPLATE, params)
58
58
59 # TODO Dedup this into PagedMixin
59 # TODO Dedup this into PagedMixin
60 def get_page_context(self, paginator, params, page):
60 def get_page_context(self, paginator, params, page):
61 """
61 """
62 Get pagination context variables
62 Get pagination context variables
63 """
63 """
64
64
65 params[PARAMETER_PAGINATOR] = paginator
65 params[PARAMETER_PAGINATOR] = paginator
66 current_page = paginator.page(int(page))
66 current_page = paginator.page(int(page))
67 params[PARAMETER_CURRENT_PAGE] = current_page
67 params[PARAMETER_CURRENT_PAGE] = current_page
68 if current_page.has_previous():
68 if current_page.has_previous():
69 params[PARAMETER_PREV_LINK] = paginator.get_page_url(
69 params[PARAMETER_PREV_LINK] = paginator.get_page_url(
70 current_page.previous_page_number())
70 current_page.previous_page_number())
71 if current_page.has_next():
71 if current_page.has_next():
72 params[PARAMETER_NEXT_LINK] = paginator.get_page_url(
72 params[PARAMETER_NEXT_LINK] = paginator.get_page_url(
73 current_page.next_page_number())
73 current_page.next_page_number())
74
74
@@ -1,183 +1,181 b''
1 from django.contrib.auth.decorators import permission_required
1 from django.contrib.auth.decorators import permission_required
2
2
3 from django.core.exceptions import ObjectDoesNotExist
3 from django.core.exceptions import ObjectDoesNotExist
4 from django.core.urlresolvers import reverse
4 from django.core.urlresolvers import reverse
5 from django.http import Http404
5 from django.http import Http404
6 from django.shortcuts import get_object_or_404, render, redirect
6 from django.shortcuts import get_object_or_404, render, redirect
7 from django.template.context_processors import csrf
7 from django.template.context_processors import csrf
8 from django.utils.decorators import method_decorator
8 from django.utils.decorators import method_decorator
9 from django.views.decorators.csrf import csrf_protect
9 from django.views.decorators.csrf import csrf_protect
10 from django.views.generic.edit import FormMixin
10 from django.views.generic.edit import FormMixin
11 from django.utils import timezone
11 from django.utils import timezone
12 from django.utils.dateformat import format
12 from django.utils.dateformat import format
13
13
14 from boards import utils, settings
14 from boards import utils, settings
15 from boards.abstracts.settingsmanager import get_settings_manager
15 from boards.abstracts.settingsmanager import get_settings_manager
16 from boards.forms import PostForm, PlainErrorList
16 from boards.forms import PostForm, PlainErrorList
17 from boards.models import Post
17 from boards.models import Post
18 from boards.views.base import BaseBoardView, CONTEXT_FORM
18 from boards.views.base import BaseBoardView, CONTEXT_FORM
19 from boards.views.mixins import DispatcherMixin, PARAMETER_METHOD
19 from boards.views.mixins import DispatcherMixin, PARAMETER_METHOD
20 from boards.views.posting_mixin import PostMixin
20 from boards.views.posting_mixin import PostMixin
21 import neboard
21 import neboard
22
22
23 REQ_POST_ID = 'post_id'
23 REQ_POST_ID = 'post_id'
24
24
25 CONTEXT_LASTUPDATE = "last_update"
25 CONTEXT_LASTUPDATE = "last_update"
26 CONTEXT_THREAD = 'thread'
26 CONTEXT_THREAD = 'thread'
27 CONTEXT_WS_TOKEN = 'ws_token'
27 CONTEXT_WS_TOKEN = 'ws_token'
28 CONTEXT_WS_PROJECT = 'ws_project'
28 CONTEXT_WS_PROJECT = 'ws_project'
29 CONTEXT_WS_HOST = 'ws_host'
29 CONTEXT_WS_HOST = 'ws_host'
30 CONTEXT_WS_PORT = 'ws_port'
30 CONTEXT_WS_PORT = 'ws_port'
31 CONTEXT_WS_TIME = 'ws_token_time'
31 CONTEXT_WS_TIME = 'ws_token_time'
32 CONTEXT_MODE = 'mode'
32 CONTEXT_MODE = 'mode'
33 CONTEXT_OP = 'opening_post'
33 CONTEXT_OP = 'opening_post'
34 CONTEXT_FAVORITE = 'is_favorite'
34 CONTEXT_FAVORITE = 'is_favorite'
35 CONTEXT_RSS_URL = 'rss_url'
35 CONTEXT_RSS_URL = 'rss_url'
36
36
37 FORM_TITLE = 'title'
37 FORM_TITLE = 'title'
38 FORM_TEXT = 'text'
38 FORM_TEXT = 'text'
39 FORM_IMAGE = 'image'
39 FORM_IMAGE = 'image'
40 FORM_THREADS = 'threads'
40 FORM_THREADS = 'threads'
41
41
42
42
43 class ThreadView(BaseBoardView, PostMixin, FormMixin, DispatcherMixin):
43 class ThreadView(BaseBoardView, PostMixin, FormMixin, DispatcherMixin):
44
44
45 @method_decorator(csrf_protect)
45 @method_decorator(csrf_protect)
46 def get(self, request, post_id, form: PostForm=None):
46 def get(self, request, post_id, form: PostForm=None):
47 try:
47 try:
48 opening_post = Post.objects.get(id=post_id)
48 opening_post = Post.objects.get(id=post_id)
49 except ObjectDoesNotExist:
49 except ObjectDoesNotExist:
50 raise Http404
50 raise Http404
51
51
52 # If the tag is favorite, update the counter
52 # If the tag is favorite, update the counter
53 settings_manager = get_settings_manager(request)
53 settings_manager = get_settings_manager(request)
54 favorite = settings_manager.thread_is_fav(opening_post)
54 favorite = settings_manager.thread_is_fav(opening_post)
55 if favorite:
55 if favorite:
56 settings_manager.add_or_read_fav_thread(opening_post)
56 settings_manager.add_or_read_fav_thread(opening_post)
57
57
58 # If this is not OP, don't show it as it is
58 # If this is not OP, don't show it as it is
59 if not opening_post.is_opening():
59 if not opening_post.is_opening():
60 return redirect(opening_post.get_thread().get_opening_post()
60 return redirect(opening_post.get_thread().get_opening_post()
61 .get_absolute_url())
61 .get_absolute_url())
62
62
63 if not form:
63 if not form:
64 form = PostForm(error_class=PlainErrorList)
64 form = PostForm(error_class=PlainErrorList)
65
65
66 thread_to_show = opening_post.get_thread()
66 thread_to_show = opening_post.get_thread()
67
67
68 params = dict()
68 params = dict()
69
69
70 params[CONTEXT_FORM] = form
70 params[CONTEXT_FORM] = form
71 params[CONTEXT_LASTUPDATE] = str(thread_to_show.last_edit_time)
71 params[CONTEXT_LASTUPDATE] = str(thread_to_show.last_edit_time)
72 params[CONTEXT_THREAD] = thread_to_show
72 params[CONTEXT_THREAD] = thread_to_show
73 params[CONTEXT_MODE] = self.get_mode()
73 params[CONTEXT_MODE] = self.get_mode()
74 params[CONTEXT_OP] = opening_post
74 params[CONTEXT_OP] = opening_post
75 params[CONTEXT_FAVORITE] = favorite
75 params[CONTEXT_FAVORITE] = favorite
76 params[CONTEXT_RSS_URL] = self.get_rss_url(post_id)
76 params[CONTEXT_RSS_URL] = self.get_rss_url(post_id)
77
77
78 if settings.get_bool('External', 'WebsocketsEnabled'):
78 if settings.get_bool('External', 'WebsocketsEnabled'):
79 token_time = format(timezone.now(), u'U')
79 token_time = format(timezone.now(), u'U')
80
80
81 params[CONTEXT_WS_TIME] = token_time
81 params[CONTEXT_WS_TIME] = token_time
82 params[CONTEXT_WS_TOKEN] = utils.get_websocket_token(
82 params[CONTEXT_WS_TOKEN] = utils.get_websocket_token(
83 timestamp=token_time)
83 timestamp=token_time)
84 params[CONTEXT_WS_PROJECT] = neboard.settings.CENTRIFUGE_PROJECT_ID
84 params[CONTEXT_WS_PROJECT] = neboard.settings.CENTRIFUGE_PROJECT_ID
85 params[CONTEXT_WS_HOST] = request.get_host().split(':')[0]
85 params[CONTEXT_WS_HOST] = request.get_host().split(':')[0]
86 params[CONTEXT_WS_PORT] = neboard.settings.CENTRIFUGE_PORT
86 params[CONTEXT_WS_PORT] = neboard.settings.CENTRIFUGE_PORT
87
87
88 params.update(self.get_data(thread_to_show))
88 params.update(self.get_data(thread_to_show))
89
89
90 return render(request, self.get_template(), params)
90 return render(request, self.get_template(), params)
91
91
92 @method_decorator(csrf_protect)
92 @method_decorator(csrf_protect)
93 def post(self, request, post_id):
93 def post(self, request, post_id):
94 opening_post = get_object_or_404(Post, id=post_id)
94 opening_post = get_object_or_404(Post, id=post_id)
95
95
96 # If this is not OP, don't show it as it is
96 # If this is not OP, don't show it as it is
97 if not opening_post.is_opening():
97 if not opening_post.is_opening():
98 raise Http404
98 raise Http404
99
99
100 if PARAMETER_METHOD in request.POST:
100 if PARAMETER_METHOD in request.POST:
101 self.dispatch_method(request, opening_post)
101 self.dispatch_method(request, opening_post)
102
102
103 return redirect('thread', post_id) # FIXME Different for different modes
103 return redirect('thread', post_id) # FIXME Different for different modes
104
104
105 if not opening_post.get_thread().is_archived():
105 if not opening_post.get_thread().is_archived():
106 form = PostForm(request.POST, request.FILES,
106 form = PostForm(request.POST, request.FILES,
107 error_class=PlainErrorList)
107 error_class=PlainErrorList)
108 form.session = request.session
108 form.session = request.session
109
109
110 if form.is_valid():
110 if form.is_valid():
111 return self.new_post(request, form, opening_post)
111 return self.new_post(request, form, opening_post)
112 if form.need_to_ban:
112 if form.need_to_ban:
113 # Ban user because he is suspected to be a bot
113 # Ban user because he is suspected to be a bot
114 self._ban_current_user(request)
114 self._ban_current_user(request)
115
115
116 return self.get(request, post_id, form)
116 return self.get(request, post_id, form)
117
117
118 def new_post(self, request, form: PostForm, opening_post: Post=None,
118 def new_post(self, request, form: PostForm, opening_post: Post=None,
119 html_response=True):
119 html_response=True):
120 """
120 """
121 Adds a new post (in thread or as a reply).
121 Adds a new post (in thread or as a reply).
122 """
122 """
123
123
124 ip = utils.get_client_ip(request)
124 ip = utils.get_client_ip(request)
125
125
126 data = form.cleaned_data
126 data = form.cleaned_data
127
127
128 title = form.get_title()
128 title = form.get_title()
129 text = data[FORM_TEXT]
129 text = data[FORM_TEXT]
130 file = form.get_file()
130 file = form.get_file()
131 file_url = form.get_file_url()
131 file_url = form.get_file_url()
132 threads = data[FORM_THREADS]
133 images = form.get_images()
132 images = form.get_images()
134
133
135 text = self._remove_invalid_links(text)
134 text = self._remove_invalid_links(text)
136
135
137 post_thread = opening_post.get_thread()
136 post_thread = opening_post.get_thread()
138
137
139 post = Post.objects.create_post(title=title, text=text, file=file,
138 post = Post.objects.create_post(title=title, text=text, file=file,
140 thread=post_thread, ip=ip,
139 thread=post_thread, ip=ip,
141 opening_posts=threads,
142 tripcode=form.get_tripcode(),
140 tripcode=form.get_tripcode(),
143 images=images, file_url=file_url)
141 images=images, file_url=file_url)
144 post.notify_clients()
142 post.notify_clients()
145
143
146 if form.is_subscribe():
144 if form.is_subscribe():
147 settings_manager = get_settings_manager(request)
145 settings_manager = get_settings_manager(request)
148 settings_manager.add_or_read_fav_thread(
146 settings_manager.add_or_read_fav_thread(
149 post_thread.get_opening_post())
147 post_thread.get_opening_post())
150
148
151 if html_response:
149 if html_response:
152 if opening_post:
150 if opening_post:
153 return redirect(post.get_absolute_url())
151 return redirect(post.get_absolute_url())
154 else:
152 else:
155 return post
153 return post
156
154
157 def get_data(self, thread) -> dict:
155 def get_data(self, thread) -> dict:
158 """
156 """
159 Returns context params for the view.
157 Returns context params for the view.
160 """
158 """
161
159
162 return dict()
160 return dict()
163
161
164 def get_template(self) -> str:
162 def get_template(self) -> str:
165 """
163 """
166 Gets template to show the thread mode on.
164 Gets template to show the thread mode on.
167 """
165 """
168
166
169 pass
167 pass
170
168
171 def get_mode(self) -> str:
169 def get_mode(self) -> str:
172 pass
170 pass
173
171
174 def subscribe(self, request, opening_post):
172 def subscribe(self, request, opening_post):
175 settings_manager = get_settings_manager(request)
173 settings_manager = get_settings_manager(request)
176 settings_manager.add_or_read_fav_thread(opening_post)
174 settings_manager.add_or_read_fav_thread(opening_post)
177
175
178 def unsubscribe(self, request, opening_post):
176 def unsubscribe(self, request, opening_post):
179 settings_manager = get_settings_manager(request)
177 settings_manager = get_settings_manager(request)
180 settings_manager.del_fav_thread(opening_post)
178 settings_manager.del_fav_thread(opening_post)
181
179
182 def get_rss_url(self, opening_id):
180 def get_rss_url(self, opening_id):
183 return reverse('thread', kwargs={'post_id': opening_id}) + 'rss/'
181 return reverse('thread', kwargs={'post_id': opening_id}) + 'rss/'
General Comments 0
You need to be logged in to leave comments. Login now