##// END OF EJS Templates
Merged with default branch
neko259 -
r1015:c84f21f3 merge decentral
parent child Browse files
Show More
@@ -0,0 +1,22 b''
1 VERSION = '2.5.1 Yasako'
2 SITE_NAME = 'Neboard'
3
4 CACHE_TIMEOUT = 600 # Timeout for caching, if cache is used
5 LOGIN_TIMEOUT = 3600 # Timeout between login tries
6 MAX_TEXT_LENGTH = 30000 # Max post length in characters
7 MAX_IMAGE_SIZE = 8 * 1024 * 1024 # Max image size
8
9 # Thread bumplimit
10 MAX_POSTS_PER_THREAD = 10
11 # Old posts will be archived or deleted if this value is reached
12 MAX_THREAD_COUNT = 5
13 THREADS_PER_PAGE = 3
14 DEFAULT_THEME = 'md'
15 LAST_REPLIES_COUNT = 3
16
17 # Enable archiving threads instead of deletion when the thread limit is reached
18 ARCHIVE_THREADS = True
19 # Limit posting speed
20 LIMIT_POSTING_SPEED = False
21 # Thread update
22 WEBSOCKETS_ENABLED = True
@@ -0,0 +1,18 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0004_tag_required'),
11 ]
12
13 operations = [
14 migrations.RemoveField(
15 model_name='thread',
16 name='replies',
17 ),
18 ]
@@ -0,0 +1,19 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0005_remove_thread_replies'),
11 ]
12
13 operations = [
14 migrations.AlterField(
15 model_name='thread',
16 name='bump_time',
17 field=models.DateTimeField(db_index=True),
18 ),
19 ]
@@ -0,0 +1,30 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 def thread_to_threads(apps, schema_editor):
10 Post = apps.get_model('boards', 'Post')
11 for post in Post.objects.all():
12 post.threads.add(post.thread_new)
13
14 dependencies = [
15 ('boards', '0006_auto_20150201_2130'),
16 ]
17
18 operations = [
19 migrations.AddField(
20 model_name='post',
21 name='threads',
22 field=models.ManyToManyField(to='boards.Thread'),
23 preserve_default=True,
24 ),
25 migrations.RunPython(thread_to_threads),
26 migrations.RemoveField(
27 model_name='post',
28 name='thread_new',
29 ),
30 ]
@@ -0,0 +1,19 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0007_auto_20150205_1247'),
11 ]
12
13 operations = [
14 migrations.AlterField(
15 model_name='post',
16 name='threads',
17 field=models.ManyToManyField(to='boards.Thread', db_index=True),
18 ),
19 ]
@@ -0,0 +1,28 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 def first_thread_to_thread(apps, schema_editor):
10 Post = apps.get_model('boards', 'Post')
11 for post in Post.objects.all():
12 post.thread = post.threads.first()
13 post.save(update_fields=['thread'])
14
15
16 dependencies = [
17 ('boards', '0008_auto_20150205_1304'),
18 ]
19
20 operations = [
21 migrations.AddField(
22 model_name='post',
23 name='thread',
24 field=models.ForeignKey(related_name='pt+', to='boards.Thread', default=None, null=True),
25 preserve_default=False,
26 ),
27 migrations.RunPython(first_thread_to_thread),
28 ]
@@ -0,0 +1,44 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 def clean_duplicate_tags(apps, schema_editor):
10 Tag = apps.get_model('boards', 'Tag')
11 for tag in Tag.objects.all():
12 tags_with_same_name = Tag.objects.filter(name=tag.name).all()
13 if len(tags_with_same_name) > 1:
14 for tag_duplicate in tags_with_same_name[1:]:
15 threads = tag_duplicate.thread_set.all()
16 for thread in threads:
17 thread.tags.add(tag)
18 tag_duplicate.delete()
19
20 dependencies = [
21 ('boards', '0009_post_thread'),
22 ]
23
24 operations = [
25 migrations.AlterField(
26 model_name='post',
27 name='thread',
28 field=models.ForeignKey(to='boards.Thread', related_name='pt+'),
29 preserve_default=True,
30 ),
31 migrations.RunPython(clean_duplicate_tags),
32 migrations.AlterField(
33 model_name='tag',
34 name='name',
35 field=models.CharField(db_index=True, unique=True, max_length=100),
36 preserve_default=True,
37 ),
38 migrations.AlterField(
39 model_name='tag',
40 name='required',
41 field=models.BooleanField(db_index=True, default=False),
42 preserve_default=True,
43 ),
44 ]
@@ -0,0 +1,25 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0010_auto_20150208_1451'),
11 ]
12
13 operations = [
14 migrations.CreateModel(
15 name='Notification',
16 fields=[
17 ('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)),
18 ('name', models.TextField()),
19 ('post', models.ForeignKey(to='boards.Post')),
20 ],
21 options={
22 },
23 bases=(models.Model,),
24 ),
25 ]
@@ -0,0 +1,63 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0011_notification'),
11 ]
12
13 operations = [
14 migrations.CreateModel(
15 name='GlobalId',
16 fields=[
17 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
18 ('key', models.TextField()),
19 ('key_type', models.TextField()),
20 ('local_id', models.IntegerField()),
21 ],
22 options={
23 },
24 bases=(models.Model,),
25 ),
26 migrations.CreateModel(
27 name='KeyPair',
28 fields=[
29 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
30 ('public_key', models.TextField()),
31 ('private_key', models.TextField()),
32 ('key_type', models.TextField()),
33 ('primary', models.BooleanField(default=False)),
34 ],
35 options={
36 },
37 bases=(models.Model,),
38 ),
39 migrations.CreateModel(
40 name='Signature',
41 fields=[
42 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
43 ('key_type', models.TextField()),
44 ('key', models.TextField()),
45 ('signature', models.TextField()),
46 ],
47 options={
48 },
49 bases=(models.Model,),
50 ),
51 migrations.AddField(
52 model_name='post',
53 name='global_id',
54 field=models.OneToOneField(to='boards.GlobalId', null=True, blank=True),
55 preserve_default=True,
56 ),
57 migrations.AddField(
58 model_name='post',
59 name='signature',
60 field=models.ManyToManyField(null=True, blank=True, to='boards.Signature'),
61 preserve_default=True,
62 ),
63 ]
@@ -0,0 +1,44 b''
1 from django.shortcuts import render
2 from boards.abstracts.paginator import get_paginator
3 from boards.abstracts.settingsmanager import get_settings_manager, \
4 SETTING_USERNAME, SETTING_LAST_NOTIFICATION_ID
5 from boards.models import Post
6 from boards.models.user import Notification
7 from boards.views.base import BaseBoardView
8
9 TEMPLATE = 'boards/notifications.html'
10 PARAM_PAGE = 'page'
11 PARAM_USERNAME = 'notification_username'
12 REQUEST_PAGE = 'page'
13 RESULTS_PER_PAGE = 10
14
15
16 class NotificationView(BaseBoardView):
17
18 def get(self, request, username):
19 params = self.get_context_data()
20
21 settings_manager = get_settings_manager(request)
22
23 # If we open our notifications, reset the "new" count
24 my_username = settings_manager.get_setting(SETTING_USERNAME)
25
26 notification_username = username.lower()
27
28 posts = Notification.objects.get_notification_posts(
29 username=notification_username)
30 if notification_username == my_username:
31 last = posts.first()
32 if last is not None:
33 last_id = last.id
34 settings_manager.set_setting(SETTING_LAST_NOTIFICATION_ID,
35 last_id)
36
37 paginator = get_paginator(posts, RESULTS_PER_PAGE)
38
39 page = int(request.GET.get(REQUEST_PAGE, '1'))
40
41 params[PARAM_PAGE] = paginator.page(page)
42 params[PARAM_USERNAME] = notification_username
43
44 return render(request, TEMPLATE, params)
@@ -0,0 +1,3 b''
1 from boards.views.thread.thread import ThreadView
2 from boards.views.thread.normal import NormalThreadView
3 from boards.views.thread.gallery import GalleryThreadView
@@ -0,0 +1,19 b''
1 from boards.views.thread import ThreadView
2
3 TEMPLATE_GALLERY = 'boards/thread_gallery.html'
4
5 CONTEXT_POSTS = 'posts'
6
7
8 class GalleryThreadView(ThreadView):
9
10 def get_template(self):
11 return TEMPLATE_GALLERY
12
13 def get_data(self, thread):
14 params = dict()
15
16 params[CONTEXT_POSTS] = thread.get_replies_with_images(
17 view_fields_only=True)
18
19 return params
@@ -0,0 +1,38 b''
1 from boards import settings
2 from boards.views.thread import ThreadView
3
4 TEMPLATE_NORMAL = 'boards/thread.html'
5
6 CONTEXT_OP = 'opening_post'
7 CONTEXT_BUMPLIMIT_PRG = 'bumplimit_progress'
8 CONTEXT_POSTS_LEFT = 'posts_left'
9 CONTEXT_BUMPABLE = 'bumpable'
10
11 FORM_TITLE = 'title'
12 FORM_TEXT = 'text'
13 FORM_IMAGE = 'image'
14
15 MODE_GALLERY = 'gallery'
16 MODE_NORMAL = 'normal'
17
18
19 class NormalThreadView(ThreadView):
20
21 def get_template(self):
22 return TEMPLATE_NORMAL
23
24 def get_data(self, thread):
25 params = dict()
26
27 bumpable = thread.can_bump()
28 params[CONTEXT_BUMPABLE] = bumpable
29 if bumpable:
30 left_posts = settings.MAX_POSTS_PER_THREAD \
31 - thread.get_reply_count()
32 params[CONTEXT_POSTS_LEFT] = left_posts
33 params[CONTEXT_BUMPLIMIT_PRG] = str(
34 float(left_posts) / settings.MAX_POSTS_PER_THREAD * 100)
35
36 params[CONTEXT_OP] = thread.get_opening_post()
37
38 return params
@@ -0,0 +1,133 b''
1 from django.core.urlresolvers import reverse
2 from django.core.exceptions import ObjectDoesNotExist
3 from django.db import transaction
4 from django.http import Http404
5 from django.shortcuts import get_object_or_404, render, redirect
6 from django.views.generic.edit import FormMixin
7
8 from boards import utils, settings
9 from boards.forms import PostForm, PlainErrorList
10 from boards.models import Post, Ban
11 from boards.views.banned import BannedView
12 from boards.views.base import BaseBoardView, CONTEXT_FORM
13 from boards.views.posting_mixin import PostMixin
14 import neboard
15
16 TEMPLATE_GALLERY = 'boards/thread_gallery.html'
17 TEMPLATE_NORMAL = 'boards/thread.html'
18
19 CONTEXT_POSTS = 'posts'
20 CONTEXT_OP = 'opening_post'
21 CONTEXT_BUMPLIMIT_PRG = 'bumplimit_progress'
22 CONTEXT_POSTS_LEFT = 'posts_left'
23 CONTEXT_LASTUPDATE = "last_update"
24 CONTEXT_MAX_REPLIES = 'max_replies'
25 CONTEXT_THREAD = 'thread'
26 CONTEXT_BUMPABLE = 'bumpable'
27 CONTEXT_WS_TOKEN = 'ws_token'
28 CONTEXT_WS_PROJECT = 'ws_project'
29 CONTEXT_WS_HOST = 'ws_host'
30 CONTEXT_WS_PORT = 'ws_port'
31
32 FORM_TITLE = 'title'
33 FORM_TEXT = 'text'
34 FORM_IMAGE = 'image'
35
36
37 class ThreadView(BaseBoardView, PostMixin, FormMixin):
38
39 def get(self, request, post_id, form: PostForm=None):
40 try:
41 opening_post = Post.objects.get(id=post_id)
42 except ObjectDoesNotExist:
43 raise Http404
44
45 # If this is not OP, don't show it as it is
46 if not opening_post.is_opening():
47 return redirect(opening_post.get_thread().get_opening_post().get_url())
48
49 if not form:
50 form = PostForm(error_class=PlainErrorList)
51
52 thread_to_show = opening_post.get_thread()
53
54 params = dict()
55
56 params[CONTEXT_FORM] = form
57 params[CONTEXT_LASTUPDATE] = str(utils.datetime_to_epoch(
58 thread_to_show.last_edit_time))
59 params[CONTEXT_THREAD] = thread_to_show
60 params[CONTEXT_MAX_REPLIES] = settings.MAX_POSTS_PER_THREAD
61
62 if settings.WEBSOCKETS_ENABLED:
63 params[CONTEXT_WS_TOKEN] = utils.get_websocket_token(
64 timestamp=params[CONTEXT_LASTUPDATE])
65 params[CONTEXT_WS_PROJECT] = neboard.settings.CENTRIFUGE_PROJECT_ID
66 params[CONTEXT_WS_HOST] = request.get_host().split(':')[0]
67 params[CONTEXT_WS_PORT] = neboard.settings.CENTRIFUGE_PORT
68
69 params.update(self.get_data(thread_to_show))
70
71 return render(request, self.get_template(), params)
72
73 def post(self, request, post_id):
74 opening_post = get_object_or_404(Post, id=post_id)
75
76 # If this is not OP, don't show it as it is
77 if not opening_post.is_opening():
78 raise Http404
79
80 if not opening_post.get_thread().archived:
81 form = PostForm(request.POST, request.FILES,
82 error_class=PlainErrorList)
83 form.session = request.session
84
85 if form.is_valid():
86 return self.new_post(request, form, opening_post)
87 if form.need_to_ban:
88 # Ban user because he is suspected to be a bot
89 self._ban_current_user(request)
90
91 return self.get(request, post_id, form)
92
93 def new_post(self, request, form: PostForm, opening_post: Post=None,
94 html_response=True):
95 """
96 Adds a new post (in thread or as a reply).
97 """
98
99 ip = utils.get_client_ip(request)
100
101 data = form.cleaned_data
102
103 title = data[FORM_TITLE]
104 text = data[FORM_TEXT]
105 image = form.get_image()
106
107 text = self._remove_invalid_links(text)
108
109 post_thread = opening_post.get_thread()
110
111 post = Post.objects.create_post(title=title, text=text, image=image,
112 thread=post_thread, ip=ip)
113 post.send_to_websocket(request)
114
115 if html_response:
116 if opening_post:
117 return redirect(post.get_url())
118 else:
119 return post
120
121 def get_data(self, thread):
122 """
123 Returns context params for the view.
124 """
125
126 pass
127
128 def get_template(self):
129 """
130 Gets template to show the thread mode on.
131 """
132
133 pass
@@ -20,3 +20,8 b' 4d998aba79e4abf0a2e78e93baaa2c2800b1c49c'
20 07fdef4ac33a859250d03f17c594089792bca615 2.2.1
20 07fdef4ac33a859250d03f17c594089792bca615 2.2.1
21 bcc74d45f060ecd3ff06ff448165aea0d026cb3e 2.2.2
21 bcc74d45f060ecd3ff06ff448165aea0d026cb3e 2.2.2
22 b0e629ff24eb47a449ecfb455dc6cc600d18c9e2 2.2.3
22 b0e629ff24eb47a449ecfb455dc6cc600d18c9e2 2.2.3
23 1b52ba60f17fd7c90912c14d9d17e880b7952d01 2.2.4
24 957e2fec91468f739b0fc2b9936d564505048c68 2.3.0
25 bb91141c6ea5c822ccbe2d46c3c48bdab683b77d 2.4.0
26 97eb184637e5691b288eaf6b03e8971f3364c239 2.5.0
27 119fafc5381b933bf30d97be0b278349f6135075 2.5.1
@@ -12,19 +12,12 b" SETTING_THEME = 'theme'"
12 SETTING_FAVORITE_TAGS = 'favorite_tags'
12 SETTING_FAVORITE_TAGS = 'favorite_tags'
13 SETTING_HIDDEN_TAGS = 'hidden_tags'
13 SETTING_HIDDEN_TAGS = 'hidden_tags'
14 SETTING_PERMISSIONS = 'permissions'
14 SETTING_PERMISSIONS = 'permissions'
15 SETTING_USERNAME = 'username'
16 SETTING_LAST_NOTIFICATION_ID = 'last_notification'
15
17
16 DEFAULT_THEME = 'md'
18 DEFAULT_THEME = 'md'
17
19
18
20
19 def get_settings_manager(request):
20 """
21 Get settings manager based on the request object. Currently only
22 session-based manager is supported. In the future, cookie-based or
23 database-based managers could be implemented.
24 """
25 return SessionSettingsManager(request.session)
26
27
28 class SettingsManager:
21 class SettingsManager:
29 """
22 """
30 Base settings manager class. get_setting and set_setting methods should
23 Base settings manager class. get_setting and set_setting methods should
@@ -77,9 +70,7 b' class SettingsManager:'
77 tag_names = self.get_setting(SETTING_FAVORITE_TAGS)
70 tag_names = self.get_setting(SETTING_FAVORITE_TAGS)
78 tags = []
71 tags = []
79 if tag_names:
72 if tag_names:
80 for tag_name in tag_names:
73 tags = Tag.objects.filter(name__in=tag_names)
81 tag = get_object_or_404(Tag, name=tag_name)
82 tags.append(tag)
83 return tags
74 return tags
84
75
85 def add_fav_tag(self, tag):
76 def add_fav_tag(self, tag):
@@ -145,3 +136,11 b' class SessionSettingsManager(SettingsMan'
145 def set_setting(self, setting, value):
136 def set_setting(self, setting, value):
146 self.session[setting] = value
137 self.session[setting] = value
147
138
139
140 def get_settings_manager(request) -> SettingsManager:
141 """
142 Get settings manager based on the request object. Currently only
143 session-based manager is supported. In the future, cookie-based or
144 database-based managers could be implemented.
145 """
146 return SessionSettingsManager(request.session)
@@ -1,15 +1,27 b''
1 from django.contrib import admin
1 from django.contrib import admin
2 from boards.models import Post, Tag, Ban, Thread, KeyPair
2 from boards.models import Post, Tag, Ban, Thread, KeyPair
3 from django.utils.translation import ugettext_lazy as _
3
4
4
5
5 @admin.register(Post)
6 @admin.register(Post)
6 class PostAdmin(admin.ModelAdmin):
7 class PostAdmin(admin.ModelAdmin):
7
8
8 list_display = ('id', 'title', 'text')
9 list_display = ('id', 'title', 'text')
9 list_filter = ('pub_time', 'thread_new')
10 list_filter = ('pub_time',)
10 search_fields = ('id', 'title', 'text')
11 search_fields = ('id', 'title', 'text')
11 exclude = ('referenced_posts', 'refmap')
12 exclude = ('referenced_posts', 'refmap')
12 readonly_fields = ('poster_ip', 'thread_new')
13 readonly_fields = ('poster_ip', 'threads', 'thread', 'images')
14
15 def ban_poster(self, request, queryset):
16 bans = 0
17 for post in queryset:
18 poster_ip = post.poster_ip
19 ban, created = Ban.objects.get_or_create(ip=poster_ip)
20 if created:
21 bans += 1
22 self.message_user(request, _('{} posters were banned').format(bans))
23
24 actions = ['ban_poster']
13
25
14
26
15 @admin.register(Tag)
27 @admin.register(Tag)
@@ -1,4 +1,6 b''
1 from boards.abstracts.settingsmanager import get_settings_manager
1 from boards.abstracts.settingsmanager import get_settings_manager, \
2 SETTING_USERNAME, SETTING_LAST_NOTIFICATION_ID
3 from boards.models.user import Notification
2
4
3 __author__ = 'neko259'
5 __author__ = 'neko259'
4
6
@@ -13,10 +15,34 b" CONTEXT_THEME = 'theme'"
13 CONTEXT_PPD = 'posts_per_day'
15 CONTEXT_PPD = 'posts_per_day'
14 CONTEXT_TAGS = 'tags'
16 CONTEXT_TAGS = 'tags'
15 CONTEXT_USER = 'user'
17 CONTEXT_USER = 'user'
18 CONTEXT_NEW_NOTIFICATIONS_COUNT = 'new_notifications_count'
19 CONTEXT_USERNAME = 'username'
16
20
17 PERMISSION_MODERATE = 'moderation'
21 PERMISSION_MODERATE = 'moderation'
18
22
19
23
24 def get_notifications(context, request):
25 settings_manager = get_settings_manager(request)
26 username = settings_manager.get_setting(SETTING_USERNAME)
27 new_notifications_count = 0
28 if username is not None and len(username) > 0:
29 last_notification_id = settings_manager.get_setting(
30 SETTING_LAST_NOTIFICATION_ID)
31
32 new_notifications_count = Notification.objects.get_notification_posts(
33 username=username, last=last_notification_id).count()
34 context[CONTEXT_NEW_NOTIFICATIONS_COUNT] = new_notifications_count
35 context[CONTEXT_USERNAME] = username
36
37
38 def get_moderator_permissions(context, request):
39 try:
40 moderate = request.user.has_perm(PERMISSION_MODERATE)
41 except AttributeError:
42 moderate = False
43 context[CONTEXT_MODERATOR] = moderate
44
45
20 def user_and_ui_processor(request):
46 def user_and_ui_processor(request):
21 context = dict()
47 context = dict()
22
48
@@ -29,13 +55,11 b' def user_and_ui_processor(request):'
29 context[CONTEXT_THEME_CSS] = 'css/' + theme + '/base_page.css'
55 context[CONTEXT_THEME_CSS] = 'css/' + theme + '/base_page.css'
30
56
31 # This shows the moderator panel
57 # This shows the moderator panel
32 try:
58 get_moderator_permissions(context, request)
33 moderate = request.user.has_perm(PERMISSION_MODERATE)
34 except AttributeError:
35 moderate = False
36 context[CONTEXT_MODERATOR] = moderate
37
59
38 context[CONTEXT_VERSION] = settings.VERSION
60 context[CONTEXT_VERSION] = settings.VERSION
39 context[CONTEXT_SITE_NAME] = settings.SITE_NAME
61 context[CONTEXT_SITE_NAME] = settings.SITE_NAME
40
62
63 get_notifications(context, request)
64
41 return context
65 return context
@@ -1,29 +1,38 b''
1 import re
1 import re
2 import time
2 import time
3 import hashlib
4
3
5 from django import forms
4 from django import forms
5 from django.core.files.uploadedfile import SimpleUploadedFile
6 from django.forms.util import ErrorList
6 from django.forms.util import ErrorList
7 from django.utils.translation import ugettext_lazy as _
7 from django.utils.translation import ugettext_lazy as _
8 import requests
8
9
9 from boards.mdx_neboard import formatters
10 from boards.mdx_neboard import formatters
10 from boards.models.post import TITLE_MAX_LENGTH
11 from boards.models.post import TITLE_MAX_LENGTH
11 from boards.models import PostImage, Tag
12 from boards.models import Tag
12 from neboard import settings
13 from neboard import settings
13 from boards import utils
14 import boards.settings as board_settings
14 import boards.settings as board_settings
15
15
16
17 CONTENT_TYPE_IMAGE = (
18 'image/jpeg',
19 'image/png',
20 'image/gif',
21 'image/bmp',
22 )
23
24 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
25
16 VETERAN_POSTING_DELAY = 5
26 VETERAN_POSTING_DELAY = 5
17
27
18 ATTRIBUTE_PLACEHOLDER = 'placeholder'
28 ATTRIBUTE_PLACEHOLDER = 'placeholder'
29 ATTRIBUTE_ROWS = 'rows'
19
30
20 LAST_POST_TIME = 'last_post_time'
31 LAST_POST_TIME = 'last_post_time'
21 LAST_LOGIN_TIME = 'last_login_time'
32 LAST_LOGIN_TIME = 'last_login_time'
22 TEXT_PLACEHOLDER = _('''Type message here. Use formatting panel for more advanced usage.''')
33 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
23 TAGS_PLACEHOLDER = _('tag1 several_words_tag')
34 TAGS_PLACEHOLDER = _('tag1 several_words_tag')
24
35
25 ERROR_IMAGE_DUPLICATE = _('Such image was already posted')
26
27 LABEL_TITLE = _('Title')
36 LABEL_TITLE = _('Title')
28 LABEL_TEXT = _('Text')
37 LABEL_TEXT = _('Text')
29 LABEL_TAG = _('Tag')
38 LABEL_TAG = _('Tag')
@@ -31,10 +40,19 b" LABEL_SEARCH = _('Search')"
31
40
32 TAG_MAX_LENGTH = 20
41 TAG_MAX_LENGTH = 20
33
42
34 REGEX_TAG = r'^[\w\d]+$'
43 IMAGE_DOWNLOAD_CHUNK_BYTES = 100000
44
45 HTTP_RESULT_OK = 200
46
47 TEXTAREA_ROWS = 4
35
48
36
49
37 class FormatPanel(forms.Textarea):
50 class FormatPanel(forms.Textarea):
51 """
52 Panel for text formatting. Consists of buttons to add different tags to the
53 form text area.
54 """
55
38 def render(self, name, value, attrs=None):
56 def render(self, name, value, attrs=None):
39 output = '<div id="mark-panel">'
57 output = '<div id="mark-panel">'
40 for formatter in formatters:
58 for formatter in formatters:
@@ -59,6 +77,9 b' class PlainErrorList(ErrorList):'
59
77
60
78
61 class NeboardForm(forms.Form):
79 class NeboardForm(forms.Form):
80 """
81 Form with neboard-specific formatting.
82 """
62
83
63 def as_div(self):
84 def as_div(self):
64 """
85 """
@@ -102,11 +123,18 b' class PostForm(NeboardForm):'
102 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
123 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
103 label=LABEL_TITLE)
124 label=LABEL_TITLE)
104 text = forms.CharField(
125 text = forms.CharField(
105 widget=FormatPanel(attrs={ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER}),
126 widget=FormatPanel(attrs={
127 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
128 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
129 }),
106 required=False, label=LABEL_TEXT)
130 required=False, label=LABEL_TEXT)
107 image = forms.ImageField(required=False, label=_('Image'),
131 image = forms.ImageField(required=False, label=_('Image'),
108 widget=forms.ClearableFileInput(
132 widget=forms.ClearableFileInput(
109 attrs={'accept': 'image/*'}))
133 attrs={'accept': 'image/*'}))
134 image_url = forms.CharField(required=False, label=_('Image URL'),
135 widget=forms.TextInput(
136 attrs={ATTRIBUTE_PLACEHOLDER:
137 'http://example.com/image.png'}))
110
138
111 # This field is for spam prevention only
139 # This field is for spam prevention only
112 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
140 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
@@ -137,18 +165,22 b' class PostForm(NeboardForm):'
137
165
138 def clean_image(self):
166 def clean_image(self):
139 image = self.cleaned_data['image']
167 image = self.cleaned_data['image']
140 if image:
168
141 if image.size > board_settings.MAX_IMAGE_SIZE:
169 self._validate_image(image)
142 raise forms.ValidationError(
170
143 _('Image must be less than %s bytes')
171 return image
144 % str(board_settings.MAX_IMAGE_SIZE))
172
173 def clean_image_url(self):
174 url = self.cleaned_data['image_url']
145
175
146 md5 = hashlib.md5()
176 image = None
147 for chunk in image.chunks():
177 if url:
148 md5.update(chunk)
178 image = self._get_image_from_url(url)
149 image_hash = md5.hexdigest()
179
150 if PostImage.objects.filter(hash=image_hash).exists():
180 if not image:
151 raise forms.ValidationError(ERROR_IMAGE_DUPLICATE)
181 raise forms.ValidationError(_('Invalid URL'))
182
183 self._validate_image(image)
152
184
153 return image
185 return image
154
186
@@ -170,14 +202,29 b' class PostForm(NeboardForm):'
170
202
171 return cleaned_data
203 return cleaned_data
172
204
205 def get_image(self):
206 """
207 Gets image from file or URL.
208 """
209
210 image = self.cleaned_data['image']
211 return image if image else self.cleaned_data['image_url']
212
173 def _clean_text_image(self):
213 def _clean_text_image(self):
174 text = self.cleaned_data.get('text')
214 text = self.cleaned_data.get('text')
175 image = self.cleaned_data.get('image')
215 image = self.get_image()
176
216
177 if (not text) and (not image):
217 if (not text) and (not image):
178 error_message = _('Either text or image must be entered.')
218 error_message = _('Either text or image must be entered.')
179 self._errors['text'] = self.error_class([error_message])
219 self._errors['text'] = self.error_class([error_message])
180
220
221 def _validate_image(self, image):
222 if image:
223 if image.size > board_settings.MAX_IMAGE_SIZE:
224 raise forms.ValidationError(
225 _('Image must be less than %s bytes')
226 % str(board_settings.MAX_IMAGE_SIZE))
227
181 def _validate_posting_speed(self):
228 def _validate_posting_speed(self):
182 can_post = True
229 can_post = True
183
230
@@ -200,11 +247,56 b' class PostForm(NeboardForm):'
200 if can_post:
247 if can_post:
201 self.session[LAST_POST_TIME] = time.time()
248 self.session[LAST_POST_TIME] = time.time()
202
249
250 def _get_image_from_url(self, url: str) -> SimpleUploadedFile:
251 """
252 Gets an image file from URL.
253 """
254
255 img_temp = None
256
257 try:
258 # Verify content headers
259 response_head = requests.head(url, verify=False)
260 content_type = response_head.headers['content-type'].split(';')[0]
261 if content_type in CONTENT_TYPE_IMAGE:
262 length_header = response_head.headers.get('content-length')
263 if length_header:
264 length = int(length_header)
265 if length > board_settings.MAX_IMAGE_SIZE:
266 raise forms.ValidationError(
267 _('Image must be less than %s bytes')
268 % str(board_settings.MAX_IMAGE_SIZE))
269
270 # Get the actual content into memory
271 response = requests.get(url, verify=False, stream=True)
272
273 # Download image, stop if the size exceeds limit
274 size = 0
275 content = b''
276 for chunk in response.iter_content(IMAGE_DOWNLOAD_CHUNK_BYTES):
277 size += len(chunk)
278 if size > board_settings.MAX_IMAGE_SIZE:
279 # TODO Dedup this code into a method
280 raise forms.ValidationError(
281 _('Image must be less than %s bytes')
282 % str(board_settings.MAX_IMAGE_SIZE))
283 content += chunk
284
285 if response.status_code == HTTP_RESULT_OK and content:
286 # Set a dummy file name that will be replaced
287 # anyway, just keep the valid extension
288 filename = 'image.' + content_type.split('/')[1]
289 img_temp = SimpleUploadedFile(filename, content,
290 content_type)
291 except Exception:
292 # Just return no image
293 pass
294
295 return img_temp
296
203
297
204 class ThreadForm(PostForm):
298 class ThreadForm(PostForm):
205
299
206 regex_tags = re.compile(r'^[\w\s\d]+$', re.UNICODE)
207
208 tags = forms.CharField(
300 tags = forms.CharField(
209 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
301 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
210 max_length=100, label=_('Tags'), required=True)
302 max_length=100, label=_('Tags'), required=True)
@@ -212,17 +304,17 b' class ThreadForm(PostForm):'
212 def clean_tags(self):
304 def clean_tags(self):
213 tags = self.cleaned_data['tags'].strip()
305 tags = self.cleaned_data['tags'].strip()
214
306
215 if not tags or not self.regex_tags.match(tags):
307 if not tags or not REGEX_TAGS.match(tags):
216 raise forms.ValidationError(
308 raise forms.ValidationError(
217 _('Inappropriate characters in tags.'))
309 _('Inappropriate characters in tags.'))
218
310
219 tag_models = []
220 required_tag_exists = False
311 required_tag_exists = False
221 for tag in tags.split():
312 for tag in tags.split():
222 tag_model = Tag.objects.filter(name=tag.strip().lower(),
313 tag_model = Tag.objects.filter(name=tag.strip().lower(),
223 required=True)
314 required=True)
224 if tag_model.exists():
315 if tag_model.exists():
225 required_tag_exists = True
316 required_tag_exists = True
317 break
226
318
227 if not required_tag_exists:
319 if not required_tag_exists:
228 raise forms.ValidationError(_('Need at least 1 required tag.'))
320 raise forms.ValidationError(_('Need at least 1 required tag.'))
@@ -239,67 +331,16 b' class SettingsForm(NeboardForm):'
239
331
240 theme = forms.ChoiceField(choices=settings.THEMES,
332 theme = forms.ChoiceField(choices=settings.THEMES,
241 label=_('Theme'))
333 label=_('Theme'))
242
334 username = forms.CharField(label=_('User name'), required=False)
243
244 class AddTagForm(NeboardForm):
245
335
246 tag = forms.CharField(max_length=TAG_MAX_LENGTH, label=LABEL_TAG)
336 def clean_username(self):
247 method = forms.CharField(widget=forms.HiddenInput(), initial='add_tag')
337 username = self.cleaned_data['username']
248
249 def clean_tag(self):
250 tag = self.cleaned_data['tag']
251
338
252 regex_tag = re.compile(REGEX_TAG, re.UNICODE)
339 if username and not REGEX_TAGS.match(username):
253 if not regex_tag.match(tag):
340 raise forms.ValidationError(_('Inappropriate characters.'))
254 raise forms.ValidationError(_('Inappropriate characters in tags.'))
255
341
256 return tag
342 return username
257
258 def clean(self):
259 cleaned_data = super(AddTagForm, self).clean()
260
261 return cleaned_data
262
343
263
344
264 class SearchForm(NeboardForm):
345 class SearchForm(NeboardForm):
265 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
346 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
266
267
268 class LoginForm(NeboardForm):
269
270 password = forms.CharField()
271
272 session = None
273
274 def clean_password(self):
275 password = self.cleaned_data['password']
276 if board_settings.MASTER_PASSWORD != password:
277 raise forms.ValidationError(_('Invalid master password'))
278
279 return password
280
281 def _validate_login_speed(self):
282 can_post = True
283
284 if LAST_LOGIN_TIME in self.session:
285 now = time.time()
286 last_login_time = self.session[LAST_LOGIN_TIME]
287
288 current_delay = int(now - last_login_time)
289
290 if current_delay < board_settings.LOGIN_TIMEOUT:
291 error_message = _('Wait %s minutes after last login') % str(
292 (board_settings.LOGIN_TIMEOUT - current_delay) / 60)
293 self._errors['password'] = self.error_class([error_message])
294
295 can_post = False
296
297 if can_post:
298 self.session[LAST_LOGIN_TIME] = time.time()
299
300 def clean(self):
301 self._validate_login_speed()
302
303 cleaned_data = super(LoginForm, self).clean()
304
305 return cleaned_data
1 NO CONTENT: modified file, binary diff hidden
NO CONTENT: modified file, binary diff hidden
@@ -7,7 +7,7 b' msgid ""'
7 msgstr ""
7 msgstr ""
8 "Project-Id-Version: PACKAGE VERSION\n"
8 "Project-Id-Version: PACKAGE VERSION\n"
9 "Report-Msgid-Bugs-To: \n"
9 "Report-Msgid-Bugs-To: \n"
10 "POT-Creation-Date: 2015-01-08 16:36+0200\n"
10 "POT-Creation-Date: 2015-03-03 23:49+0200\n"
11 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
11 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
12 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
12 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
13 "Language-Team: LANGUAGE <LL@li.org>\n"
13 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -18,6 +18,10 b' msgstr ""'
18 "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
18 "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
19 "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
19 "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
20
20
21 #: admin.py:22
22 msgid "{} posters were banned"
23 msgstr ""
24
21 #: authors.py:9
25 #: authors.py:9
22 msgid "author"
26 msgid "author"
23 msgstr "автор"
27 msgstr "автор"
@@ -34,92 +38,95 b' msgstr "\xd1\x80\xd0\xb0\xd0\xb7\xd1\x80\xd0\xb0\xd0\xb1\xd0\xbe\xd1\x82\xd1\x87\xd0\xb8\xd0\xba javascript"'
34 msgid "designer"
38 msgid "designer"
35 msgstr "дизайнер"
39 msgstr "дизайнер"
36
40
37 #: forms.py:22
41 #: forms.py:33
38 msgid "Type message here. Use formatting panel for more advanced usage."
42 msgid "Type message here. Use formatting panel for more advanced usage."
39 msgstr ""
43 msgstr ""
40 "Вводите сообщение сюда. Используйте панель для более сложного форматирования."
44 "Вводите сообщение сюда. Используйте панель для более сложного форматирования."
41
45
42 #: forms.py:23
46 #: forms.py:34
43 msgid "tag1 several_words_tag"
47 msgid "tag1 several_words_tag"
44 msgstr "метка1 метка_из_нескольких_слов"
48 msgstr "метка1 метка_из_нескольких_слов"
45
49
46 #: forms.py:25
50 #: forms.py:36
47 msgid "Such image was already posted"
48 msgstr "Такое изображение уже было загружено"
49
50 #: forms.py:27
51 msgid "Title"
51 msgid "Title"
52 msgstr "Заголовок"
52 msgstr "Заголовок"
53
53
54 #: forms.py:28
54 #: forms.py:37
55 msgid "Text"
55 msgid "Text"
56 msgstr "Текст"
56 msgstr "Текст"
57
57
58 #: forms.py:29
58 #: forms.py:38
59 msgid "Tag"
59 msgid "Tag"
60 msgstr "Метка"
60 msgstr "Метка"
61
61
62 #: forms.py:30 templates/boards/base.html:38 templates/search/search.html:9
62 #: forms.py:39 templates/boards/base.html:38 templates/search/search.html:9
63 #: templates/search/search.html.py:13
63 #: templates/search/search.html.py:13
64 msgid "Search"
64 msgid "Search"
65 msgstr "Поиск"
65 msgstr "Поиск"
66
66
67 #: forms.py:107
67 #: forms.py:131
68 msgid "Image"
68 msgid "Image"
69 msgstr "Изображение"
69 msgstr "Изображение"
70
70
71 #: forms.py:112
71 #: forms.py:134
72 msgid "Image URL"
73 msgstr "URL изображения"
74
75 #: forms.py:140
72 msgid "e-mail"
76 msgid "e-mail"
73 msgstr ""
77 msgstr ""
74
78
75 #: forms.py:123
79 #: forms.py:151
76 #, python-format
80 #, python-format
77 msgid "Title must have less than %s characters"
81 msgid "Title must have less than %s characters"
78 msgstr "Заголовок должен иметь меньше %s символов"
82 msgstr "Заголовок должен иметь меньше %s символов"
79
83
80 #: forms.py:132
84 #: forms.py:160
81 #, python-format
85 #, python-format
82 msgid "Text must have less than %s characters"
86 msgid "Text must have less than %s characters"
83 msgstr "Текст должен быть короче %s символов"
87 msgstr "Текст должен быть короче %s символов"
84
88
85 #: forms.py:143
89 #: forms.py:181
90 msgid "Invalid URL"
91 msgstr "Неверный URL"
92
93 #: forms.py:218
94 msgid "Either text or image must be entered."
95 msgstr "Текст или картинка должны быть введены."
96
97 #: forms.py:225 forms.py:267 forms.py:281
86 #, python-format
98 #, python-format
87 msgid "Image must be less than %s bytes"
99 msgid "Image must be less than %s bytes"
88 msgstr "Изображение должно быть менее %s байт"
100 msgstr "Изображение должно быть менее %s байт"
89
101
90 #: forms.py:178
102 #: forms.py:241
91 msgid "Either text or image must be entered."
92 msgstr "Текст или картинка должны быть введены."
93
94 #: forms.py:194
95 #, python-format
103 #, python-format
96 msgid "Wait %s seconds after last posting"
104 msgid "Wait %s seconds after last posting"
97 msgstr "Подождите %s секунд после последнего постинга"
105 msgstr "Подождите %s секунд после последнего постинга"
98
106
99 #: forms.py:210 templates/boards/rss/post.html:10 templates/boards/tags.html:7
107 #: forms.py:302 templates/boards/rss/post.html:10 templates/boards/tags.html:7
100 msgid "Tags"
108 msgid "Tags"
101 msgstr "Метки"
109 msgstr "Метки"
102
110
103 #: forms.py:217 forms.py:254
111 #: forms.py:309
104 msgid "Inappropriate characters in tags."
112 msgid "Inappropriate characters in tags."
105 msgstr "Недопустимые символы в метках."
113 msgstr "Недопустимые символы в метках."
106
114
107 #: forms.py:228
115 #: forms.py:320
108 msgid "Need at least 1 required tag."
116 msgid "Need at least 1 required tag."
109 msgstr "Нужна хотя бы 1 обязательная метка."
117 msgstr "Нужна хотя бы 1 обязательная метка."
110
118
111 #: forms.py:241
119 #: forms.py:333
112 msgid "Theme"
120 msgid "Theme"
113 msgstr "Тема"
121 msgstr "Тема"
114
122
115 #: forms.py:277
123 #: forms.py:334
116 msgid "Invalid master password"
124 msgid "User name"
117 msgstr "Неверный мастер-пароль"
125 msgstr "Имя пользователя"
118
126
119 #: forms.py:291
127 #: forms.py:340
120 #, python-format
128 msgid "Inappropriate characters."
121 msgid "Wait %s minutes after last login"
129 msgstr "Недопустимые символы."
122 msgstr "Подождите %s минут после последнего входа"
123
130
124 #: templates/boards/404.html:6
131 #: templates/boards/404.html:6
125 msgid "Not found"
132 msgid "Not found"
@@ -157,20 +164,28 b' msgstr "\xd0\x92\xd1\x81\xd0\xb5 \xd1\x82\xd0\xb5\xd0\xbc\xd1\x8b"'
157 msgid "Tag management"
164 msgid "Tag management"
158 msgstr "Управление метками"
165 msgstr "Управление метками"
159
166
160 #: templates/boards/base.html:39 templates/boards/settings.html:7
167 #: templates/boards/base.html:41 templates/boards/notifications.html:7
168 msgid "Notifications"
169 msgstr "Уведомления"
170
171 #: templates/boards/base.html:41
172 msgid "notifications"
173 msgstr "уведомлений"
174
175 #: templates/boards/base.html:44 templates/boards/settings.html:7
161 msgid "Settings"
176 msgid "Settings"
162 msgstr "Настройки"
177 msgstr "Настройки"
163
178
164 #: templates/boards/base.html:52
179 #: templates/boards/base.html:57
165 msgid "Admin"
180 msgid "Admin"
166 msgstr ""
181 msgstr "Администрирование"
167
182
168 #: templates/boards/base.html:54
183 #: templates/boards/base.html:59
169 #, python-format
184 #, python-format
170 msgid "Speed: %(ppd)s posts per day"
185 msgid "Speed: %(ppd)s posts per day"
171 msgstr "Скорость: %(ppd)s сообщений в день"
186 msgstr "Скорость: %(ppd)s сообщений в день"
172
187
173 #: templates/boards/base.html:56
188 #: templates/boards/base.html:61
174 msgid "Up"
189 msgid "Up"
175 msgstr "Вверх"
190 msgstr "Вверх"
176
191
@@ -182,96 +197,95 b' msgstr "\xd0\x92\xd1\x85\xd0\xbe\xd0\xb4"'
182 msgid "Insert your user id above"
197 msgid "Insert your user id above"
183 msgstr "Вставьте свой ID пользователя выше"
198 msgstr "Вставьте свой ID пользователя выше"
184
199
200 #: templates/boards/notifications.html:16
201 #: templates/boards/posting_general.html:79 templates/search/search.html:22
202 msgid "Previous page"
203 msgstr "Предыдущая страница"
204
205 #: templates/boards/notifications.html:26
206 #: templates/boards/posting_general.html:117 templates/search/search.html:33
207 msgid "Next page"
208 msgstr "Следующая страница"
209
185 #: templates/boards/post.html:19 templates/boards/staticpages/help.html:17
210 #: templates/boards/post.html:19 templates/boards/staticpages/help.html:17
186 msgid "Quote"
211 msgid "Quote"
187 msgstr "Цитата"
212 msgstr "Цитата"
188
213
189 #: templates/boards/post.html:27
214 #: templates/boards/post.html:35
190 msgid "Open"
215 msgid "Open"
191 msgstr "Открыть"
216 msgstr "Открыть"
192
217
193 #: templates/boards/post.html:29
218 #: templates/boards/post.html:37
194 msgid "Reply"
219 msgid "Reply"
195 msgstr "Ответ"
220 msgstr "Ответ"
196
221
197 #: templates/boards/post.html:36
222 #: templates/boards/post.html:43
198 msgid "Edit"
223 msgid "Edit"
199 msgstr "Изменить"
224 msgstr "Изменить"
200
225
201 #: templates/boards/post.html:39
226 #: templates/boards/post.html:45
202 msgid "Edit thread"
227 msgid "Edit thread"
203 msgstr "Изменить тему"
228 msgstr "Изменить тему"
204
229
205 #: templates/boards/post.html:71
230 #: templates/boards/post.html:75
206 msgid "Replies"
231 msgid "Replies"
207 msgstr "Ответы"
232 msgstr "Ответы"
208
233
209 #: templates/boards/post.html:79 templates/boards/thread.html:89
234 #: templates/boards/post.html:86 templates/boards/thread.html:86
210 #: templates/boards/thread_gallery.html:59
235 #: templates/boards/thread_gallery.html:59
211 msgid "messages"
236 msgid "messages"
212 msgstr "сообщений"
237 msgstr "сообщений"
213
238
214 #: templates/boards/post.html:80 templates/boards/thread.html:90
239 #: templates/boards/post.html:87 templates/boards/thread.html:87
215 #: templates/boards/thread_gallery.html:60
240 #: templates/boards/thread_gallery.html:60
216 msgid "images"
241 msgid "images"
217 msgstr "изображений"
242 msgstr "изображений"
218
243
219 #: templates/boards/post_admin.html:19
244 #: templates/boards/posting_general.html:52
220 msgid "Tags:"
221 msgstr "Метки:"
222
223 #: templates/boards/post_admin.html:30
224 msgid "Add tag"
225 msgstr "Добавить метку"
226
227 #: templates/boards/posting_general.html:56
228 msgid "Show tag"
245 msgid "Show tag"
229 msgstr "Показывать метку"
246 msgstr "Показывать метку"
230
247
231 #: templates/boards/posting_general.html:60
248 #: templates/boards/posting_general.html:56
232 msgid "Hide tag"
249 msgid "Hide tag"
233 msgstr "Скрывать метку"
250 msgstr "Скрывать метку"
234
251
235 #: templates/boards/posting_general.html:66
252 #: templates/boards/posting_general.html:63
236 msgid "Edit tag"
253 msgid "Edit tag"
237 msgstr "Изменить метку"
254 msgstr "Изменить метку"
238
255
239 #: templates/boards/posting_general.html:82 templates/search/search.html:22
256 #: templates/boards/posting_general.html:66
240 msgid "Previous page"
257 #, python-format
241 msgstr "Предыдущая страница"
258 msgid "This tag has %(thread_count)s threads and %(post_count)s posts."
259 msgstr "С этой меткой есть %(thread_count)s тем и %(post_count)s сообщений."
242
260
243 #: templates/boards/posting_general.html:97
261 #: templates/boards/posting_general.html:93
244 #, python-format
262 #, python-format
245 msgid "Skipped %(count)s replies. Open thread to see all replies."
263 msgid "Skipped %(count)s replies. Open thread to see all replies."
246 msgstr "Пропущено %(count)s ответов. Откройте тред, чтобы увидеть все ответы."
264 msgstr "Пропущено %(count)s ответов. Откройте тред, чтобы увидеть все ответы."
247
265
248 #: templates/boards/posting_general.html:124 templates/search/search.html:33
266 #: templates/boards/posting_general.html:122
249 msgid "Next page"
250 msgstr "Следующая страница"
251
252 #: templates/boards/posting_general.html:129
253 msgid "No threads exist. Create the first one!"
267 msgid "No threads exist. Create the first one!"
254 msgstr "Нет тем. Создайте первую!"
268 msgstr "Нет тем. Создайте первую!"
255
269
256 #: templates/boards/posting_general.html:135
270 #: templates/boards/posting_general.html:128
257 msgid "Create new thread"
271 msgid "Create new thread"
258 msgstr "Создать новую тему"
272 msgstr "Создать новую тему"
259
273
260 #: templates/boards/posting_general.html:140 templates/boards/preview.html:16
274 #: templates/boards/posting_general.html:133 templates/boards/preview.html:16
261 #: templates/boards/thread.html:54
275 #: templates/boards/thread.html:53
262 msgid "Post"
276 msgid "Post"
263 msgstr "Отправить"
277 msgstr "Отправить"
264
278
265 #: templates/boards/posting_general.html:145
279 #: templates/boards/posting_general.html:139
266 msgid "Tags must be delimited by spaces. Text or image is required."
280 msgid "Tags must be delimited by spaces. Text or image is required."
267 msgstr ""
281 msgstr ""
268 "Метки должны быть разделены пробелами. Текст или изображение обязательны."
282 "Метки должны быть разделены пробелами. Текст или изображение обязательны."
269
283
270 #: templates/boards/posting_general.html:148 templates/boards/thread.html:62
284 #: templates/boards/posting_general.html:142 templates/boards/thread.html:59
271 msgid "Text syntax"
285 msgid "Text syntax"
272 msgstr "Синтаксис текста"
286 msgstr "Синтаксис текста"
273
287
274 #: templates/boards/posting_general.html:160
288 #: templates/boards/posting_general.html:154
275 msgid "Pages:"
289 msgid "Pages:"
276 msgstr "Страницы: "
290 msgstr "Страницы: "
277
291
@@ -291,11 +305,11 b' msgstr "\xd0\x92\xd1\x8b \xd0\xbc\xd0\xbe\xd0\xb4\xd0\xb5\xd1\x80\xd0\xb0\xd1\x82\xd0\xbe\xd1\x80."'
291 msgid "Hidden tags:"
305 msgid "Hidden tags:"
292 msgstr "Скрытые метки:"
306 msgstr "Скрытые метки:"
293
307
294 #: templates/boards/settings.html:26
308 #: templates/boards/settings.html:27
295 msgid "No hidden tags."
309 msgid "No hidden tags."
296 msgstr "Нет скрытых меток."
310 msgstr "Нет скрытых меток."
297
311
298 #: templates/boards/settings.html:35
312 #: templates/boards/settings.html:36
299 msgid "Save"
313 msgid "Save"
300 msgstr "Сохранить"
314 msgstr "Сохранить"
301
315
@@ -360,11 +374,10 b' msgstr "\xd1\x81\xd0\xbe\xd0\xbe\xd0\xb1\xd1\x89\xd0\xb5\xd0\xbd\xd0\xb8\xd0\xb9 \xd0\xb4\xd0\xbe \xd0\xb1\xd0\xb0\xd0\xbc\xd0\xbf\xd0\xbb\xd0\xb8\xd0\xbc\xd0\xb8\xd1\x82\xd0\xb0"'
360 msgid "Reply to thread"
374 msgid "Reply to thread"
361 msgstr "Ответить в тему"
375 msgstr "Ответить в тему"
362
376
363 #: templates/boards/thread.html:59
377 #: templates/boards/thread.html:85
364 msgid "Switch mode"
378 msgid "Update"
365 msgstr "Переключить режим"
379 msgstr "Обновить"
366
380
367 #: templates/boards/thread.html:91 templates/boards/thread_gallery.html:61
381 #: templates/boards/thread.html:88 templates/boards/thread_gallery.html:61
368 msgid "Last update: "
382 msgid "Last update: "
369 msgstr "Последнее обновление: "
383 msgstr "Последнее обновление: "
370
@@ -4,7 +4,7 b''
4 from django.core.management import BaseCommand
4 from django.core.management import BaseCommand
5 from django.db import transaction
5 from django.db import transaction
6
6
7 from boards.models import KeyPair
7 from boards.models import KeyPair, Post
8
8
9
9
10 class Command(BaseCommand):
10 class Command(BaseCommand):
@@ -12,6 +12,11 b' class Command(BaseCommand):'
12
12
13 @transaction.atomic
13 @transaction.atomic
14 def handle(self, *args, **options):
14 def handle(self, *args, **options):
15 first_key = not KeyPair.objects.has_primary()
15 key = KeyPair.objects.generate_key(
16 key = KeyPair.objects.generate_key(
16 primary=not KeyPair.objects.has_primary())
17 primary=first_key)
17 print(key) No newline at end of file
18 print(key)
19
20 if first_key:
21 for post in Post.objects.filter(global_id=None):
22 post.set_global_id() No newline at end of file
@@ -2,6 +2,7 b''
2
2
3 import re
3 import re
4 import bbcode
4 import bbcode
5 from django.core.urlresolvers import reverse
5
6
6 import boards
7 import boards
7
8
@@ -153,7 +154,6 b' def render_quote(tag_name, value, option'
153 if 'source' in options:
154 if 'source' in options:
154 source = options['source']
155 source = options['source']
155
156
156 result = ''
157 if source:
157 if source:
158 result = '<div class="multiquote"><div class="quote-header">%s</div><div class="quote-text">%s</div></div>' % (source, value)
158 result = '<div class="multiquote"><div class="quote-header">%s</div><div class="quote-text">%s</div></div>' % (source, value)
159 else:
159 else:
@@ -162,6 +162,13 b' def render_quote(tag_name, value, option'
162 return result
162 return result
163
163
164
164
165 def render_notification(tag_name, value, options, parent, content):
166 username = value.lower()
167
168 return '<a href="{}" class="user-cast">@{}</a>'.format(
169 reverse('notifications', kwargs={'username': username}), username)
170
171
165 def preparse_text(text):
172 def preparse_text(text):
166 """
173 """
167 Performs manual parsing before the bbcode parser is used.
174 Performs manual parsing before the bbcode parser is used.
@@ -176,11 +183,11 b' def bbcode_extended(markup):'
176 parser = bbcode.Parser(newline='<div class="br"></div>')
183 parser = bbcode.Parser(newline='<div class="br"></div>')
177 parser.add_formatter('post', render_reflink, strip=True)
184 parser.add_formatter('post', render_reflink, strip=True)
178 parser.add_formatter('quote', render_quote, strip=True)
185 parser.add_formatter('quote', render_quote, strip=True)
186 parser.add_formatter('user', render_notification, strip=True)
179 parser.add_simple_formatter('comment',
187 parser.add_simple_formatter('comment',
180 '<span class="comment">//%(value)s</span>')
188 '<span class="comment">//%(value)s</span>')
181 parser.add_simple_formatter('spoiler',
189 parser.add_simple_formatter('spoiler',
182 '<span class="spoiler">%(value)s</span>')
190 '<span class="spoiler">%(value)s</span>')
183 # TODO Use <s> here
184 parser.add_simple_formatter('s',
191 parser.add_simple_formatter('s',
185 '<span class="strikethrough">%(value)s</span>')
192 '<span class="strikethrough">%(value)s</span>')
186 # TODO Why not use built-in tag?
193 # TODO Why not use built-in tag?
@@ -56,10 +56,7 b' class PostImage(models.Model, Viewable):'
56 """
56 """
57
57
58 if not self.pk and self.image:
58 if not self.pk and self.image:
59 md5 = hashlib.md5()
59 self.hash = PostImage.get_hash(self.image)
60 for chunk in self.image.chunks():
61 md5.update(chunk)
62 self.hash = md5.hexdigest()
63 super(PostImage, self).save(*args, **kwargs)
60 super(PostImage, self).save(*args, **kwargs)
64
61
65 def __str__(self):
62 def __str__(self):
@@ -81,3 +78,13 b' class PostImage(models.Model, Viewable):'
81 self.image.url_200x150,
78 self.image.url_200x150,
82 str(self.hash), str(self.pre_width),
79 str(self.hash), str(self.pre_width),
83 str(self.pre_height), str(self.width), str(self.height))
80 str(self.pre_height), str(self.width), str(self.height))
81
82 @staticmethod
83 def get_hash(image):
84 """
85 Gets hash of an image.
86 """
87 md5 = hashlib.md5()
88 for chunk in image.chunks():
89 md5.update(chunk)
90 return md5.hexdigest()
@@ -3,23 +3,24 b' from datetime import time as dtime'
3 import logging
3 import logging
4 import re
4 import re
5 import xml.etree.ElementTree as et
5 import xml.etree.ElementTree as et
6 from urllib.parse import unquote
6
7
7 from adjacent import Client
8 from adjacent import Client
8 from django.core.cache import cache
9 from django.core.urlresolvers import reverse
9 from django.core.urlresolvers import reverse
10 from django.db import models, transaction
10 from django.db import models, transaction
11 from django.db.models import TextField
11 from django.db.models import TextField
12 from django.template.loader import render_to_string
12 from django.template.loader import render_to_string
13 from django.utils import timezone
13 from django.utils import timezone
14
14
15 from boards.models import PostImage, KeyPair, GlobalId, Signature
15 from boards.models import KeyPair, GlobalId, Signature
16 from boards import settings
16 from boards import settings, utils
17 from boards.mdx_neboard import bbcode_extended
17 from boards.mdx_neboard import bbcode_extended
18 from boards.models import PostImage
18 from boards.models import PostImage
19 from boards.models.base import Viewable
19 from boards.models.base import Viewable
20 from boards.models.thread import Thread
20 from boards.utils import datetime_to_epoch, cached_result
21 from boards import utils
21 from boards.models.user import Notification
22 from boards.utils import datetime_to_epoch
22 import boards.models.thread
23
23
24
24 ENCODING_UNICODE = 'unicode'
25 ENCODING_UNICODE = 'unicode'
25
26
@@ -30,9 +31,6 b' WS_CHANNEL_THREAD = "thread:"'
30
31
31 APP_LABEL_BOARDS = 'boards'
32 APP_LABEL_BOARDS = 'boards'
32
33
33 CACHE_KEY_PPD = 'ppd'
34 CACHE_KEY_POST_URL = 'post_url'
35
36 POSTS_PER_DAY_RANGE = 7
34 POSTS_PER_DAY_RANGE = 7
37
35
38 BAN_REASON_AUTO = 'Auto'
36 BAN_REASON_AUTO = 'Auto'
@@ -49,6 +47,8 b" UNKNOWN_UA = ''"
49
47
50 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
48 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
51 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
49 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
50 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
51 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
52
52
53 TAG_MODEL = 'model'
53 TAG_MODEL = 'model'
54 TAG_REQUEST = 'request'
54 TAG_REQUEST = 'request'
@@ -111,8 +111,8 b' class PostManager(models.Manager):'
111
111
112 posting_time = timezone.now()
112 posting_time = timezone.now()
113 if not thread:
113 if not thread:
114 thread = Thread.objects.create(bump_time=posting_time,
114 thread = boards.models.thread.Thread.objects.create(
115 last_edit_time=posting_time)
115 bump_time=posting_time, last_edit_time=posting_time)
116 new_thread = True
116 new_thread = True
117 else:
117 else:
118 new_thread = False
118 new_thread = False
@@ -122,11 +122,12 b' class PostManager(models.Manager):'
122 post = self.create(title=title,
122 post = self.create(title=title,
123 text=pre_text,
123 text=pre_text,
124 pub_time=posting_time,
124 pub_time=posting_time,
125 thread_new=thread,
126 poster_ip=ip,
125 poster_ip=ip,
126 thread=thread,
127 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
127 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
128 # last!
128 # last!
129 last_edit_time=posting_time)
129 last_edit_time=posting_time)
130 post.threads.add(thread)
130
131
131 post.set_global_id()
132 post.set_global_id()
132
133
@@ -136,22 +137,29 b' class PostManager(models.Manager):'
136 post, post.poster_ip))
137 post, post.poster_ip))
137
138
138 if image:
139 if image:
139 post_image = PostImage.objects.create(image=image)
140 # Try to find existing image. If it exists, assign it to the post
141 # instead of createing the new one
142 image_hash = PostImage.get_hash(image)
143 existing = PostImage.objects.filter(hash=image_hash)
144 if len(existing) > 0:
145 post_image = existing[0]
146 else:
147 post_image = PostImage.objects.create(image=image)
148 logger.info('Created new image #{} for post #{}'.format(
149 post_image.id, post.id))
140 post.images.add(post_image)
150 post.images.add(post_image)
141 logger.info('Created image #{} for post #{}'.format(
142 post_image.id, post.id))
143
151
144 thread.replies.add(post)
145 list(map(thread.add_tag, tags))
152 list(map(thread.add_tag, tags))
146
153
147 if new_thread:
154 if new_thread:
148 Thread.objects.process_oldest_threads()
155 boards.models.thread.Thread.objects.process_oldest_threads()
149 else:
156 else:
150 thread.bump()
157 thread.bump()
151 thread.last_edit_time = posting_time
158 thread.last_edit_time = posting_time
152 thread.save()
159 thread.save()
153
160
154 self.connect_replies(post)
161 post.connect_replies()
162 post.connect_notifications()
155
163
156 return post
164 return post
157
165
@@ -164,25 +172,7 b' class PostManager(models.Manager):'
164 for post in posts:
172 for post in posts:
165 post.delete()
173 post.delete()
166
174
167 # TODO This can be moved into a post
175 @cached_result
168 def connect_replies(self, post):
169 """
170 Connects replies to a post to show them as a reflink map
171 """
172
173 for reply_number in post.get_replied_ids():
174 ref_post = self.filter(id=reply_number)
175 if ref_post.count() > 0:
176 referenced_post = ref_post[0]
177 referenced_post.referenced_posts.add(post)
178 referenced_post.last_edit_time = post.pub_time
179 referenced_post.build_refmap()
180 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
181
182 referenced_thread = referenced_post.get_thread()
183 referenced_thread.last_edit_time = post.pub_time
184 referenced_thread.save(update_fields=['last_edit_time'])
185
186 def get_posts_per_day(self):
176 def get_posts_per_day(self):
187 """
177 """
188 Gets average count of posts per day for the last 7 days
178 Gets average count of posts per day for the last 7 days
@@ -191,11 +181,6 b' class PostManager(models.Manager):'
191 day_end = date.today()
181 day_end = date.today()
192 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
182 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
193
183
194 cache_key = CACHE_KEY_PPD + str(day_end)
195 ppd = cache.get(cache_key)
196 if ppd:
197 return ppd
198
199 day_time_start = timezone.make_aware(datetime.combine(
184 day_time_start = timezone.make_aware(datetime.combine(
200 day_start, dtime()), timezone.get_current_timezone())
185 day_start, dtime()), timezone.get_current_timezone())
201 day_time_end = timezone.make_aware(datetime.combine(
186 day_time_end = timezone.make_aware(datetime.combine(
@@ -207,7 +192,6 b' class PostManager(models.Manager):'
207
192
208 ppd = posts_per_period / POSTS_PER_DAY_RANGE
193 ppd = posts_per_period / POSTS_PER_DAY_RANGE
209
194
210 cache.set(cache_key, ppd)
211 return ppd
195 return ppd
212
196
213 # TODO Make a separate sync facade?
197 # TODO Make a separate sync facade?
@@ -295,7 +279,8 b' class PostManager(models.Manager):'
295 # TODO Throw an exception?
279 # TODO Throw an exception?
296 pass
280 pass
297
281
298 def _preparse_text(self, text):
282 # TODO Make a separate parser module and move preparser there
283 def _preparse_text(self, text: str) -> str:
299 """
284 """
300 Preparses text to change patterns like '>>' to a proper bbcode
285 Preparses text to change patterns like '>>' to a proper bbcode
301 tags.
286 tags.
@@ -304,6 +289,9 b' class PostManager(models.Manager):'
304 for key, value in PREPARSE_PATTERNS.items():
289 for key, value in PREPARSE_PATTERNS.items():
305 text = re.sub(key, value, text, flags=re.MULTILINE)
290 text = re.sub(key, value, text, flags=re.MULTILINE)
306
291
292 for link in REGEX_URL.findall(text):
293 text = text.replace(link, unquote(link))
294
307 return text
295 return text
308
296
309
297
@@ -327,19 +315,15 b' class Post(models.Model, Viewable):'
327 poster_ip = models.GenericIPAddressField()
315 poster_ip = models.GenericIPAddressField()
328 poster_user_agent = models.TextField()
316 poster_user_agent = models.TextField()
329
317
330 thread_new = models.ForeignKey('Thread', null=True, default=None,
331 db_index=True)
332 last_edit_time = models.DateTimeField()
318 last_edit_time = models.DateTimeField()
333
319
334 # Replies to the post
335 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
320 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
336 null=True,
321 null=True,
337 blank=True, related_name='rfp+',
322 blank=True, related_name='rfp+',
338 db_index=True)
323 db_index=True)
339
340 # Replies map. This is built from the referenced posts list to speed up
341 # page loading (no need to get all the referenced posts from the database).
342 refmap = models.TextField(null=True, blank=True)
324 refmap = models.TextField(null=True, blank=True)
325 threads = models.ManyToManyField('Thread', db_index=True)
326 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
343
327
344 # Global ID with author key. If the message was downloaded from another
328 # Global ID with author key. If the message was downloaded from another
345 # server, this indicates the server.
329 # server, this indicates the server.
@@ -367,26 +351,16 b' class Post(models.Model, Viewable):'
367 Builds a replies map string from replies list. This is a cache to stop
351 Builds a replies map string from replies list. This is a cache to stop
368 the server from recalculating the map on every post show.
352 the server from recalculating the map on every post show.
369 """
353 """
370 map_string = ''
354 post_urls = ['<a href="{}">&gt;&gt;{}</a>'.format(
355 refpost.get_url(), refpost.id) for refpost in self.referenced_posts.all()]
371
356
372 first = True
357 self.refmap = ', '.join(post_urls)
373 for refpost in self.referenced_posts.all():
374 if not first:
375 map_string += ', '
376 map_string += '<a href="%s">&gt;&gt;%s</a>' % (refpost.get_url(),
377 refpost.id)
378 first = False
379
380 self.refmap = map_string
381
358
382 def get_sorted_referenced_posts(self):
359 def get_sorted_referenced_posts(self):
383 return self.refmap
360 return self.refmap
384
361
385 def is_referenced(self) -> bool:
362 def is_referenced(self) -> bool:
386 if not self.refmap:
363 return self.refmap and len(self.refmap) > 0
387 return False
388 else:
389 return len(self.refmap) > 0
390
364
391 def is_opening(self) -> bool:
365 def is_opening(self) -> bool:
392 """
366 """
@@ -407,39 +381,36 b' class Post(models.Model, Viewable):'
407 thread.last_edit_time = edit_time
381 thread.last_edit_time = edit_time
408 thread.save(update_fields=['last_edit_time'])
382 thread.save(update_fields=['last_edit_time'])
409
383
410 def get_url(self, thread=None):
384 @cached_result
385 def get_url(self):
411 """
386 """
412 Gets full url to the post.
387 Gets full url to the post.
413 """
388 """
414
389
415 cache_key = CACHE_KEY_POST_URL + str(self.id)
390 thread = self.get_thread()
416 link = cache.get(cache_key)
417
391
418 if not link:
392 opening_id = thread.get_opening_post_id()
419 if not thread:
420 thread = self.get_thread()
421
393
422 opening_id = thread.get_opening_post_id()
394 if self.id != opening_id:
423
395 link = reverse('thread', kwargs={
424 if self.id != opening_id:
396 'post_id': opening_id}) + '#' + str(self.id)
425 link = reverse('thread', kwargs={
397 else:
426 'post_id': opening_id}) + '#' + str(self.id)
398 link = reverse('thread', kwargs={'post_id': self.id})
427 else:
428 link = reverse('thread', kwargs={'post_id': self.id})
429
430 cache.set(cache_key, link)
431
399
432 return link
400 return link
433
401
434 def get_thread(self) -> Thread:
402 def get_thread(self):
403 return self.thread
404
405 def get_threads(self):
435 """
406 """
436 Gets post's thread.
407 Gets post's thread.
437 """
408 """
438
409
439 return self.thread_new
410 return self.threads
440
411
441 def get_referenced_posts(self):
412 def get_referenced_posts(self):
442 return self.referenced_posts.only('id', 'thread_new')
413 return self.referenced_posts.only('id', 'threads')
443
414
444 def get_view(self, moderator=False, need_open_link=False,
415 def get_view(self, moderator=False, need_open_link=False,
445 truncated=False, *args, **kwargs):
416 truncated=False, *args, **kwargs):
@@ -449,8 +420,8 b' class Post(models.Model, Viewable):'
449 are same for every post and don't need to be computed over and over.
420 are same for every post and don't need to be computed over and over.
450 """
421 """
451
422
423 thread = self.get_thread()
452 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
424 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
453 thread = kwargs.get(PARAMETER_THREAD, self.get_thread())
454 can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump())
425 can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump())
455
426
456 if is_opening:
427 if is_opening:
@@ -477,23 +448,24 b' class Post(models.Model, Viewable):'
477
448
478 def delete(self, using=None):
449 def delete(self, using=None):
479 """
450 """
480 Deletes all post images and the post itself. If the post is opening,
451 Deletes all post images and the post itself.
481 thread with all posts is deleted.
482 """
452 """
483
453
484 self.images.all().delete()
454 for image in self.images.all():
455 image_refs_count = Post.objects.filter(images__in=[image]).count()
456 if image_refs_count == 1:
457 image.delete()
458
485 self.signature.all().delete()
459 self.signature.all().delete()
486 if self.global_id:
460 if self.global_id:
487 self.global_id.delete()
461 self.global_id.delete()
488
462
489 if self.is_opening():
463 thread = self.get_thread()
490 self.get_thread().delete()
464 thread.last_edit_time = timezone.now()
491 else:
465 thread.save()
492 thread = self.get_thread()
493 thread.last_edit_time = timezone.now()
494 thread.save()
495
466
496 super(Post, self).delete(using)
467 super(Post, self).delete(using)
468
497 logging.getLogger('boards.post.delete').info(
469 logging.getLogger('boards.post.delete').info(
498 'Deleted post {}'.format(self))
470 'Deleted post {}'.format(self))
499
471
@@ -573,7 +545,7 b' class Post(models.Model, Viewable):'
573 post_json['image_preview'] = post_image.image.url_200x150
545 post_json['image_preview'] = post_image.image.url_200x150
574 if include_last_update:
546 if include_last_update:
575 post_json['bump_time'] = datetime_to_epoch(
547 post_json['bump_time'] = datetime_to_epoch(
576 self.thread_new.bump_time)
548 self.get_thread().bump_time)
577 return post_json
549 return post_json
578
550
579 def send_to_websocket(self, request, recursive=True):
551 def send_to_websocket(self, request, recursive=True):
@@ -606,7 +578,7 b' class Post(models.Model, Viewable):'
606
578
607 # If post is in this thread, its thread was already notified.
579 # If post is in this thread, its thread was already notified.
608 # Otherwise, notify its thread separately.
580 # Otherwise, notify its thread separately.
609 if ref_post.thread_new_id != thread_id:
581 if ref_post.get_thread().id != thread_id:
610 ref_post.send_to_websocket(request, recursive=False)
582 ref_post.send_to_websocket(request, recursive=False)
611
583
612 def save(self, force_insert=False, force_update=False, using=None,
584 def save(self, force_insert=False, force_update=False, using=None,
@@ -620,3 +592,40 b' class Post(models.Model, Viewable):'
620
592
621 def get_raw_text(self) -> str:
593 def get_raw_text(self) -> str:
622 return self.text
594 return self.text
595
596 def get_absolute_id(self) -> str:
597 """
598 If the post has many threads, shows its main thread OP id in the post
599 ID.
600 """
601
602 if self.get_threads().count() > 1:
603 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
604 else:
605 return str(self.id)
606
607 def connect_notifications(self):
608 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
609 user_name = reply_number.group(1).lower()
610 Notification.objects.get_or_create(name=user_name, post=self)
611
612 def connect_replies(self):
613 """
614 Connects replies to a post to show them as a reflink map
615 """
616
617 for post_id in self.get_replied_ids():
618 ref_post = Post.objects.filter(id=post_id)
619 if ref_post.count() > 0:
620 referenced_post = ref_post[0]
621 referenced_post.referenced_posts.add(self)
622 referenced_post.last_edit_time = self.pub_time
623 referenced_post.build_refmap()
624 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
625
626 referenced_threads = referenced_post.get_threads().all()
627 for thread in referenced_threads:
628 thread.last_edit_time = self.pub_time
629 thread.save(update_fields=['last_edit_time'])
630
631 self.threads.add(thread)
@@ -4,6 +4,7 b' from django.db.models import Count'
4 from django.core.urlresolvers import reverse
4 from django.core.urlresolvers import reverse
5
5
6 from boards.models.base import Viewable
6 from boards.models.base import Viewable
7 from boards.utils import cached_result
7
8
8
9
9 __author__ = 'neko259'
10 __author__ = 'neko259'
@@ -33,8 +34,8 b' class Tag(models.Model, Viewable):'
33 app_label = 'boards'
34 app_label = 'boards'
34 ordering = ('name',)
35 ordering = ('name',)
35
36
36 name = models.CharField(max_length=100, db_index=True)
37 name = models.CharField(max_length=100, db_index=True, unique=True)
37 required = models.BooleanField(default=False)
38 required = models.BooleanField(default=False, db_index=True)
38
39
39 def __str__(self):
40 def __str__(self):
40 return self.name
41 return self.name
@@ -69,3 +70,7 b' class Tag(models.Model, Viewable):'
69 return render_to_string('boards/tag.html', {
70 return render_to_string('boards/tag.html', {
70 'tag': self,
71 'tag': self,
71 })
72 })
73
74 @cached_result
75 def get_post_count(self):
76 return self.get_threads().aggregate(num_posts=Count('post'))['num_posts']
@@ -1,9 +1,13 b''
1 import logging
1 import logging
2
2 from django.db.models import Count, Sum
3 from django.db.models import Count, Sum
3 from django.utils import timezone
4 from django.utils import timezone
4 from django.core.cache import cache
5 from django.db import models
5 from django.db import models
6
6 from boards import settings
7 from boards import settings
8 from boards.utils import cached_result
9 from boards.models.post import Post
10
7
11
8 __author__ = 'neko259'
12 __author__ = 'neko259'
9
13
@@ -11,9 +15,6 b' from boards import settings'
11 logger = logging.getLogger(__name__)
15 logger = logging.getLogger(__name__)
12
16
13
17
14 CACHE_KEY_OPENING_POST = 'opening_post_id'
15
16
17 class ThreadManager(models.Manager):
18 class ThreadManager(models.Manager):
18 def process_oldest_threads(self):
19 def process_oldest_threads(self):
19 """
20 """
@@ -50,10 +51,8 b' class Thread(models.Model):'
50 app_label = 'boards'
51 app_label = 'boards'
51
52
52 tags = models.ManyToManyField('Tag')
53 tags = models.ManyToManyField('Tag')
53 bump_time = models.DateTimeField()
54 bump_time = models.DateTimeField(db_index=True)
54 last_edit_time = models.DateTimeField()
55 last_edit_time = models.DateTimeField()
55 replies = models.ManyToManyField('Post', symmetrical=False, null=True,
56 blank=True, related_name='tre+')
57 archived = models.BooleanField(default=False)
56 archived = models.BooleanField(default=False)
58 bumpable = models.BooleanField(default=True)
57 bumpable = models.BooleanField(default=True)
59
58
@@ -78,10 +77,10 b' class Thread(models.Model):'
78 logger.info('Bumped thread %d' % self.id)
77 logger.info('Bumped thread %d' % self.id)
79
78
80 def get_reply_count(self):
79 def get_reply_count(self):
81 return self.replies.count()
80 return self.get_replies().count()
82
81
83 def get_images_count(self):
82 def get_images_count(self):
84 return self.replies.annotate(images_count=Count(
83 return self.get_replies().annotate(images_count=Count(
85 'images')).aggregate(Sum('images_count'))['images_count__sum']
84 'images')).aggregate(Sum('images_count'))['images_count__sum']
86
85
87 def can_bump(self):
86 def can_bump(self):
@@ -121,12 +120,17 b' class Thread(models.Model):'
121 Gets sorted thread posts
120 Gets sorted thread posts
122 """
121 """
123
122
124 query = self.replies.order_by('pub_time').prefetch_related('images')
123 query = Post.objects.filter(threads__in=[self])
124 query = query.order_by('pub_time').prefetch_related('images', 'thread', 'threads')
125 if view_fields_only:
125 if view_fields_only:
126 query = query.defer('poster_user_agent')
126 query = query.defer('poster_user_agent')
127 return query.all()
127 return query.all()
128
128
129 def get_replies_with_images(self, view_fields_only=False):
129 def get_replies_with_images(self, view_fields_only=False):
130 """
131 Gets replies that have at least one image attached
132 """
133
130 return self.get_replies(view_fields_only).annotate(images_count=Count(
134 return self.get_replies(view_fields_only).annotate(images_count=Count(
131 'images')).filter(images_count__gt=0)
135 'images')).filter(images_count__gt=0)
132
136
@@ -142,25 +146,20 b' class Thread(models.Model):'
142 Gets the first post of the thread
146 Gets the first post of the thread
143 """
147 """
144
148
145 query = self.replies.order_by('pub_time')
149 query = self.get_replies().order_by('pub_time')
146 if only_id:
150 if only_id:
147 query = query.only('id')
151 query = query.only('id')
148 opening_post = query.first()
152 opening_post = query.first()
149
153
150 return opening_post
154 return opening_post
151
155
156 @cached_result
152 def get_opening_post_id(self):
157 def get_opening_post_id(self):
153 """
158 """
154 Gets ID of the first thread post.
159 Gets ID of the first thread post.
155 """
160 """
156
161
157 cache_key = CACHE_KEY_OPENING_POST + str(self.id)
162 return self.get_opening_post(only_id=True).id
158 opening_post_id = cache.get(cache_key)
159 if not opening_post_id:
160 opening_post_id = self.get_opening_post(only_id=True).id
161 cache.set(cache_key, opening_post_id)
162
163 return opening_post_id
164
163
165 def __unicode__(self):
164 def __unicode__(self):
166 return str(self.id)
165 return str(self.id)
@@ -173,10 +172,14 b' class Thread(models.Model):'
173 return self.get_opening_post().pub_time
172 return self.get_opening_post().pub_time
174
173
175 def delete(self, using=None):
174 def delete(self, using=None):
176 if self.replies.exists():
175 """
177 self.replies.all().delete()
176 Deletes thread with all replies.
177 """
178
179 for reply in self.get_replies().all():
180 reply.delete()
178
181
179 super(Thread, self).delete(using)
182 super(Thread, self).delete(using)
180
183
181 def __str__(self):
184 def __str__(self):
182 return 'T#{}/{}'.format(self.id, self.get_opening_post_id()) No newline at end of file
185 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
@@ -1,5 +1,7 b''
1 from django.db import models
1 from django.db import models
2
2
3 import boards.models.post
4
3 __author__ = 'neko259'
5 __author__ = 'neko259'
4
6
5 BAN_REASON_AUTO = 'Auto'
7 BAN_REASON_AUTO = 'Auto'
@@ -18,3 +20,27 b' class Ban(models.Model):'
18
20
19 def __str__(self):
21 def __str__(self):
20 return self.ip
22 return self.ip
23
24
25 class NotificationManager(models.Manager):
26 def get_notification_posts(self, username: str, last: int = None):
27 i_username = username.lower()
28
29 posts = boards.models.post.Post.objects.filter(notification__name=i_username)
30 if last is not None:
31 posts = posts.filter(id__gt=last)
32 posts = posts.order_by('-id')
33
34 return posts
35
36
37 class Notification(models.Model):
38
39 class Meta:
40 app_label = 'boards'
41
42 objects = NotificationManager()
43
44 post = models.ForeignKey('Post')
45 name = models.TextField()
46
@@ -1,22 +1,3 b''
1 VERSION = '2.2.3 Miyu'
1 from boards.default_settings import *
2 SITE_NAME = 'Neboard'
3
4 CACHE_TIMEOUT = 600 # Timeout for caching, if cache is used
5 LOGIN_TIMEOUT = 3600 # Timeout between login tries
6 MAX_TEXT_LENGTH = 30000 # Max post length in characters
7 MAX_IMAGE_SIZE = 8 * 1024 * 1024 # Max image size
8
2
9 # Thread bumplimit
3 # Site-specific settings go here No newline at end of file
10 MAX_POSTS_PER_THREAD = 10
11 # Old posts will be archived or deleted if this value is reached
12 MAX_THREAD_COUNT = 5
13 THREADS_PER_PAGE = 3
14 DEFAULT_THEME = 'md'
15 LAST_REPLIES_COUNT = 3
16
17 # Enable archiving threads instead of deletion when the thread limit is reached
18 ARCHIVE_THREADS = True
19 # Limit posting speed
20 LIMIT_POSTING_SPEED = False
21 # Thread update
22 WEBSOCKETS_ENABLED = True
@@ -3,7 +3,7 b''
3 font-weight: inherit;
3 font-weight: inherit;
4 }
4 }
5
5
6 b {
6 b, strong {
7 font-weight: bold;
7 font-weight: bold;
8 }
8 }
9
9
@@ -344,6 +344,11 b' li {'
344 color: #e99d41;
344 color: #e99d41;
345 float: right;
345 float: right;
346 font-weight: bold;
346 font-weight: bold;
347 opacity: 0.4;
348 }
349
350 .moderator_info:hover {
351 opacity: 1;
347 }
352 }
348
353
349 .refmap {
354 .refmap {
@@ -444,7 +449,6 b' pre {'
444
449
445 .tag_item {
450 .tag_item {
446 display: inline-block;
451 display: inline-block;
447 border: 1px dashed #666;
448 margin: 0.2ex;
452 margin: 0.2ex;
449 padding: 0.1ex;
453 padding: 0.1ex;
450 }
454 }
@@ -495,3 +499,19 b' ul {'
495 .hljs, .hljs-subst, .hljs-tag .hljs-title, .lisp .hljs-title, .clojure .hljs-built_in, .nginx .hljs-title {
499 .hljs, .hljs-subst, .hljs-tag .hljs-title, .lisp .hljs-title, .clojure .hljs-built_in, .nginx .hljs-title {
496 color: #fff;
500 color: #fff;
497 }
501 }
502
503 #up {
504 position: fixed;
505 bottom: 5px;
506 right: 5px;
507 border: 1px solid #777;
508 background: #000;
509 padding: 4px;
510 }
511
512 .user-cast {
513 border: solid #ffffff 1px;
514 padding: .2ex;
515 background: #152154;
516 color: #fff;
517 } No newline at end of file
@@ -369,3 +369,20 b' li {'
369 #id_q {
369 #id_q {
370 margin-left: 1ex;
370 margin-left: 1ex;
371 }
371 }
372
373 .br {
374 margin-top: 0.5em;
375 margin-bottom: 0.5em;
376 }
377
378 .message, .refmap {
379 margin-top: .5em;
380 }
381
382 .user-cast {
383 padding: 0.2em .5ex;
384 background: #008;
385 color: #FFF;
386 display: inline-block;
387 text-decoration: none;
388 } No newline at end of file
@@ -1,5 +1,3 b''
1 var isCompact = false;
2
3 $('input[name=image]').wrap($('<div class="file_wrap"></div>'));
1 $('input[name=image]').wrap($('<div class="file_wrap"></div>'));
4
2
5 $('body').on('change', 'input[name=image]', function(event) {
3 $('body').on('change', 'input[name=image]', function(event) {
@@ -21,21 +19,9 b' var isCompact = false;'
21 }
19 }
22 });
20 });
23
21
24 var fullForm = $('.swappable-form-full');
22 var form = $('#form');
25
23 $('textarea').keypress(function(event) {
26 function swapForm() {
24 if (event.which == 13 && event.ctrlKey) {
27 if (isCompact) {
25 form.submit();
28 // TODO Use IDs (change the django form code) instead of absolute numbers
29 fullForm.find('textarea').appendTo(fullForm.find('.form-row')[4].children[0]);
30 fullForm.find('.file_wrap').appendTo(fullForm.find('.form-row')[7].children[0]);
31 fullForm.find('.form-row').show();
32
33 scrollToBottom();
34 } else {
35 fullForm.find('textarea').appendTo($('.compact-form-text'));
36 fullForm.find('.file_wrap').insertBefore($('.compact-form-text'));
37 fullForm.find('.form-row').hide();
38 fullForm.find('input[type=text]').val('');
39 }
26 }
40 isCompact = !isCompact;
27 }); No newline at end of file
41 }
@@ -23,6 +23,8 b''
23 for the JavaScript code in this page.
23 for the JavaScript code in this page.
24 */
24 */
25
25
26 var $html = $("html, body");
27
26 function moveCaretToEnd(el) {
28 function moveCaretToEnd(el) {
27 if (typeof el.selectionStart == "number") {
29 if (typeof el.selectionStart == "number") {
28 el.selectionStart = el.selectionEnd = el.value.length;
30 el.selectionStart = el.selectionEnd = el.value.length;
@@ -48,14 +50,9 b' function addQuickReply(postId) {'
48 $(textAreaId).focus();
50 $(textAreaId).focus();
49 moveCaretToEnd(textarea);
51 moveCaretToEnd(textarea);
50
52
51 $("html, body").animate({ scrollTop: $(textAreaId).offset().top }, "slow");
53 $html.animate({ scrollTop: $(textAreaId).offset().top }, "slow");
52 }
54 }
53
55
54 function scrollToBottom() {
56 function scrollToBottom() {
55 var $target = $('html,body');
57 $html.animate({scrollTop: $html.height()}, "fast");
56 $target.animate({scrollTop: $target.height()}, "fast");
58 } No newline at end of file
57 }
58
59 $(document).ready(function() {
60 swapForm();
61 })
@@ -25,7 +25,6 b''
25
25
26 var wsUser = '';
26 var wsUser = '';
27
27
28 var loading = false;
29 var unreadPosts = 0;
28 var unreadPosts = 0;
30 var documentOriginalTitle = '';
29 var documentOriginalTitle = '';
31
30
@@ -67,7 +66,7 b' function connectWebsocket() {'
67
66
68 // For the case we closed the browser and missed some updates
67 // For the case we closed the browser and missed some updates
69 getThreadDiff();
68 getThreadDiff();
70 $('#autoupdate').text('[+]');
69 $('#autoupdate').hide();
71 });
70 });
72
71
73 centrifuge.connect();
72 centrifuge.connect();
@@ -94,8 +93,6 b' function getThreadDiff() {'
94 var post = $(postText);
93 var post = $(postText);
95
94
96 updatePost(post)
95 updatePost(post)
97
98 lastPost = post;
99 }
96 }
100
97
101 var updatedPosts = data.updated;
98 var updatedPosts = data.updated;
@@ -297,8 +294,7 b' function showAsErrors(form, text) {'
297 form.children('.form-errors').remove();
294 form.children('.form-errors').remove();
298
295
299 if (text.length > 0) {
296 if (text.length > 0) {
300 var errorList = $('<div class="form-errors">' + text
297 var errorList = $('<div class="form-errors">' + text + '<div>');
301 + '<div>');
302 errorList.appendTo(form);
298 errorList.appendTo(form);
303 }
299 }
304 }
300 }
@@ -331,4 +327,6 b' function processNewPost(post) {'
331
327
332 resetForm(form);
328 resetForm(form);
333 }
329 }
330
331 $('#autoupdate').click(getThreadDiff);
334 });
332 });
@@ -28,14 +28,19 b''
28
28
29 <div class="navigation_panel header">
29 <div class="navigation_panel header">
30 <a class="link" href="{% url 'index' %}">{% trans "All threads" %}</a>
30 <a class="link" href="{% url 'index' %}">{% trans "All threads" %}</a>
31 {% for tag in tags %}
31 {% autoescape off %}
32 {% autoescape off %}
32 {% for tag in tags %}
33 {{ tag.get_view }}{% if not forloop.last %},{% endif %}
33 {{ tag.get_view }},
34 {% endautoescape %}
34 {% endfor %}
35 {% endfor %}
35 {% endautoescape %}
36 <a href="{% url 'tags' %}" title="{% trans 'Tag management' %}"
36 <a href="{% url 'tags' %}" title="{% trans 'Tag management' %}"
37 >[...]</a>,
37 >[...]</a>,
38 <a href="{% url 'search' %}" title="{% trans 'Search' %}">[S]</a>
38 <a href="{% url 'search' %}" title="{% trans 'Search' %}">[S]</a>.
39
40 {% if username %}
41 <a href="{% url 'notifications' username %}" title="{% trans 'Notifications' %}"><b>{{ new_notifications_count }}</b> {% trans 'notifications' %}</a>.
42 {% endif %}
43
39 <a class="link" href="{% url 'settings' %}">{% trans 'Settings' %}</a>
44 <a class="link" href="{% url 'settings' %}">{% trans 'Settings' %}</a>
40 </div>
45 </div>
41
46
@@ -53,7 +58,7 b''
53 {% with ppd=posts_per_day|floatformat:2 %}
58 {% with ppd=posts_per_day|floatformat:2 %}
54 {% blocktrans %}Speed: {{ ppd }} posts per day{% endblocktrans %}
59 {% blocktrans %}Speed: {{ ppd }} posts per day{% endblocktrans %}
55 {% endwith %}
60 {% endwith %}
56 <a class="link" href="#top">{% trans 'Up' %}</a>
61 <a class="link" href="#top" id="up">{% trans 'Up' %}</a>
57 </div>
62 </div>
58
63
59 </body>
64 </body>
@@ -3,35 +3,27 b''
3 {% load board %}
3 {% load board %}
4 {% load i18n %}
4 {% load i18n %}
5
5
6 {% block head %}
7 <title>{{ site_name }} - {% trans 'Notifications' %} - {{ notification_username }}</title>
8 {% endblock %}
9
6 {% block content %}
10 {% block content %}
7 <div class="post-form-w">
11 <div class="tag_info"><a href="{% url 'notifications' notification_username %}" class="user-cast">@{{ notification_username }}</a></div>
8 <div class="post-form">
9 <h3>{% trans 'Search' %}</h3>
10 <form method="get" action="">
11 {{ form.as_div }}
12 <div class="form-submit">
13 <input type="submit" value="{% trans 'Search' %}">
14 </div>
15 </form>
16 </div>
17 </div>
18
12
19 {% if page %}
13 {% if page %}
20 {% if page.has_previous %}
14 {% if page.has_previous %}
21 <div class="page_link">
15 <div class="page_link">
22 <a href="?query={{ query }}&amp;page={{ page.previous_page_number }}">{% trans "Previous page" %}
16 <a href="?page={{ page.previous_page_number }}">{% trans "Previous page" %}</a>
23 </a>
24 </div>
17 </div>
25 {% endif %}
18 {% endif %}
26
19
27 {% for result in page.object_list %}
20 {% for post in page.object_list %}
28 {{ result.object.get_search_view }}
21 {% post_view post %}
29 {% endfor %}
22 {% endfor %}
30
23
31 {% if page.has_next %}
24 {% if page.has_next %}
32 <div class="page_link">
25 <div class="page_link">
33 <a href="?query={{ query }}&amp;page={{ page.next_page_number }}">{% trans "Next page" %}
26 <a href="?page={{ page.next_page_number }}">{% trans "Next page" %}</a>
34 </a>
35 </div>
27 </div>
36 {% endif %}
28 {% endif %}
37 {% endif %}
29 {% endif %}
@@ -13,12 +13,12 b''
13 {% endif %}
13 {% endif %}
14
14
15 <div class="post-info">
15 <div class="post-info">
16 <a class="post_id" href="{% post_object_url post thread=thread %}"
16 <a class="post_id" href="{{ post.get_url }}"
17 {% if not truncated and not thread.archived %}
17 {% if not truncated and not thread.archived %}
18 onclick="javascript:addQuickReply('{{ post.id }}'); return false;"
18 onclick="javascript:addQuickReply('{{ post.id }}'); return false;"
19 title="{% trans 'Quote' %}" {% endif %}>({{ post.id }})</a>
19 title="{% trans 'Quote' %}" {% endif %}>({{ post.get_absolute_id }})</a>
20 <span class="title">{{ post.title }}</span>
20 <span class="title">{{ post.title }}</span>
21 <span class="pub_time">{{ post.pub_time }}</span>
21 <span class="pub_time">{{ post.pub_time|date:'r' }}</span>
22 {% comment %}
22 {% comment %}
23 Thread death time needs to be shown only if the thread is alredy archived
23 Thread death time needs to be shown only if the thread is alredy archived
24 and this is an opening post (thread death time) or a post for popup
24 and this is an opening post (thread death time) or a post for popup
@@ -68,7 +68,7 b''
68 <div class="message">
68 <div class="message">
69 {% autoescape off %}
69 {% autoescape off %}
70 {% if truncated %}
70 {% if truncated %}
71 {{ post.get_text|truncatewords_html:50 }}
71 {{ post.get_text|truncatewords_html:50|truncate_lines:3 }}
72 {% else %}
72 {% else %}
73 {{ post.get_text }}
73 {{ post.get_text }}
74 {% endif %}
74 {% endif %}
@@ -91,11 +91,11 b''
91 {{ thread.get_images_count }} {% trans 'images' %}.
91 {{ thread.get_images_count }} {% trans 'images' %}.
92 {% endif %}
92 {% endif %}
93 <span class="tags">
93 <span class="tags">
94 {% for tag in thread.get_tags %}
94 {% autoescape off %}
95 {% autoescape off %}
95 {% for tag in thread.get_tags %}
96 {{ tag.get_view }}{% if not forloop.last %},{% endif %}
96 {{ tag.get_view }}{% if not forloop.last %},{% endif %}
97 {% endautoescape %}
97 {% endfor %}
98 {% endfor %}
98 {% endautoescape %}
99 </span>
99 </span>
100 </div>
100 </div>
101 {% endif %}
101 {% endif %}
@@ -16,8 +16,6 b''
16 <link rel="prev" href="
16 <link rel="prev" href="
17 {% if tag %}
17 {% if tag %}
18 {% url "tag" tag_name=tag.name page=current_page.previous_page_number %}
18 {% url "tag" tag_name=tag.name page=current_page.previous_page_number %}
19 {% elif archived %}
20 {% url "archive" page=current_page.previous_page_number %}
21 {% else %}
19 {% else %}
22 {% url "index" page=current_page.previous_page_number %}
20 {% url "index" page=current_page.previous_page_number %}
23 {% endif %}
21 {% endif %}
@@ -27,8 +25,6 b''
27 <link rel="next" href="
25 <link rel="next" href="
28 {% if tag %}
26 {% if tag %}
29 {% url "tag" tag_name=tag.name page=current_page.next_page_number %}
27 {% url "tag" tag_name=tag.name page=current_page.next_page_number %}
30 {% elif archived %}
31 {% url "archive" page=current_page.next_page_number %}
32 {% else %}
28 {% else %}
33 {% url "index" page=current_page.next_page_number %}
29 {% url "index" page=current_page.next_page_number %}
34 {% endif %}
30 {% endif %}
@@ -44,14 +40,14 b''
44 {% if tag %}
40 {% if tag %}
45 <div class="tag_info">
41 <div class="tag_info">
46 <h2>
42 <h2>
47 {% if tag in fav_tags %}
43 {% if is_favorite %}
48 <a href="{% url 'tag' tag.name %}?method=unsubscribe&next={{ request.path }}"
44 <a href="{% url 'tag' tag.name %}?method=unsubscribe&next={{ request.path }}"
49 class="fav" rel="nofollow"></a>
45 class="fav" rel="nofollow"></a>
50 {% else %}
46 {% else %}
51 <a href="{% url 'tag' tag.name %}?method=subscribe&next={{ request.path }}"
47 <a href="{% url 'tag' tag.name %}?method=subscribe&next={{ request.path }}"
52 class="not_fav" rel="nofollow"></a>
48 class="not_fav" rel="nofollow"></a>
53 {% endif %}
49 {% endif %}
54 {% if tag in hidden_tags %}
50 {% if is_hidden %}
55 <a href="{% url 'tag' tag.name %}?method=unhide&next={{ request.path }}"
51 <a href="{% url 'tag' tag.name %}?method=unhide&next={{ request.path }}"
56 title="{% trans 'Show tag' %}"
52 title="{% trans 'Show tag' %}"
57 class="fav" rel="nofollow">H</a>
53 class="fav" rel="nofollow">H</a>
@@ -64,9 +60,10 b''
64 {{ tag.get_view }}
60 {{ tag.get_view }}
65 {% endautoescape %}
61 {% endautoescape %}
66 {% if moderator %}
62 {% if moderator %}
67 [<a href="{% url 'admin:boards_tag_change' tag.id %}"$>{% trans 'Edit tag' %}</a>]
63 <span class="moderator_info">[<a href="{% url 'admin:boards_tag_change' tag.id %}">{% trans 'Edit tag' %}</a>]</span>
68 {% endif %}
64 {% endif %}
69 </h2>
65 </h2>
66 <p>{% blocktrans with thread_count=tag.get_thread_count post_count=tag.get_post_count %}This tag has {{ thread_count }} threads and {{ post_count }} posts.{% endblocktrans %}</p>
70 </div>
67 </div>
71 {% endif %}
68 {% endif %}
72
69
@@ -76,8 +73,6 b''
76 <a href="
73 <a href="
77 {% if tag %}
74 {% if tag %}
78 {% url "tag" tag_name=tag.name page=current_page.previous_page_number %}
75 {% url "tag" tag_name=tag.name page=current_page.previous_page_number %}
79 {% elif archived %}
80 {% url "archive" page=current_page.previous_page_number %}
81 {% else %}
76 {% else %}
82 {% url "index" page=current_page.previous_page_number %}
77 {% url "index" page=current_page.previous_page_number %}
83 {% endif %}
78 {% endif %}
@@ -88,8 +83,7 b''
88 {% for thread in threads %}
83 {% for thread in threads %}
89 {% cache 600 thread_short thread.id thread.last_edit_time moderator LANGUAGE_CODE %}
84 {% cache 600 thread_short thread.id thread.last_edit_time moderator LANGUAGE_CODE %}
90 <div class="thread">
85 <div class="thread">
91 {% with can_bump=thread.can_bump %}
86 {% post_view thread.get_opening_post moderator is_opening=True thread=thread truncated=True need_open_link=True %}
92 {% post_view thread.get_opening_post moderator is_opening=True thread=thread can_bump=can_bump truncated=True need_open_link=True %}
93 {% if not thread.archived %}
87 {% if not thread.archived %}
94 {% with last_replies=thread.get_last_replies %}
88 {% with last_replies=thread.get_last_replies %}
95 {% if last_replies %}
89 {% if last_replies %}
@@ -102,13 +96,12 b''
102 {% endif %}
96 {% endif %}
103 <div class="last-replies">
97 <div class="last-replies">
104 {% for post in last_replies %}
98 {% for post in last_replies %}
105 {% post_view post moderator=moderator is_opening=False thread=thread can_bump=can_bump truncated=True %}
99 {% post_view post is_opening=False moderator=moderator truncated=True %}
106 {% endfor %}
100 {% endfor %}
107 </div>
101 </div>
108 {% endif %}
102 {% endif %}
109 {% endwith %}
103 {% endwith %}
110 {% endif %}
104 {% endif %}
111 {% endwith %}
112 </div>
105 </div>
113 {% endcache %}
106 {% endcache %}
114 {% endfor %}
107 {% endfor %}
@@ -118,8 +111,6 b''
118 <a href="
111 <a href="
119 {% if tag %}
112 {% if tag %}
120 {% url "tag" tag_name=tag.name page=current_page.next_page_number %}
113 {% url "tag" tag_name=tag.name page=current_page.next_page_number %}
121 {% elif archived %}
122 {% url "archive" page=current_page.next_page_number %}
123 {% else %}
114 {% else %}
124 {% url "index" page=current_page.next_page_number %}
115 {% url "index" page=current_page.next_page_number %}
125 {% endif %}
116 {% endif %}
@@ -136,11 +127,12 b''
136 <div class="post-form">
127 <div class="post-form">
137 <div class="form-title">{% trans "Create new thread" %}</div>
128 <div class="form-title">{% trans "Create new thread" %}</div>
138 <div class="swappable-form-full">
129 <div class="swappable-form-full">
139 <form enctype="multipart/form-data" method="post">{% csrf_token %}
130 <form enctype="multipart/form-data" method="post"id="form">{% csrf_token %}
140 {{ form.as_div }}
131 {{ form.as_div }}
141 <div class="form-submit">
132 <div class="form-submit">
142 <input type="submit" value="{% trans "Post" %}"/>
133 <input type="submit" value="{% trans "Post" %}"/>
143 </div>
134 </div>
135 (ctrl-enter)
144 </form>
136 </form>
145 </div>
137 </div>
146 <div>
138 <div>
@@ -163,8 +155,6 b''
163 <a href="
155 <a href="
164 {% if tag %}
156 {% if tag %}
165 {% url "tag" tag_name=tag.name page=paginator.page_range|first %}
157 {% url "tag" tag_name=tag.name page=paginator.page_range|first %}
166 {% elif archived %}
167 {% url "archive" page=paginator.page_range|first %}
168 {% else %}
158 {% else %}
169 {% url "index" page=paginator.page_range|first %}
159 {% url "index" page=paginator.page_range|first %}
170 {% endif %}
160 {% endif %}
@@ -178,8 +168,6 b''
178 href="
168 href="
179 {% if tag %}
169 {% if tag %}
180 {% url "tag" tag_name=tag.name page=page %}
170 {% url "tag" tag_name=tag.name page=page %}
181 {% elif archived %}
182 {% url "archive" page=page %}
183 {% else %}
171 {% else %}
184 {% url "index" page=page %}
172 {% url "index" page=page %}
185 {% endif %}
173 {% endif %}
@@ -190,8 +178,6 b''
190 <a href="
178 <a href="
191 {% if tag %}
179 {% if tag %}
192 {% url "tag" tag_name=tag.name page=paginator.page_range|last %}
180 {% url "tag" tag_name=tag.name page=paginator.page_range|last %}
193 {% elif archived %}
194 {% url "archive" page=paginator.page_range|last %}
195 {% else %}
181 {% else %}
196 {% url "index" page=paginator.page_range|last %}
182 {% url "index" page=paginator.page_range|last %}
197 {% endif %}
183 {% endif %}
@@ -18,8 +18,9 b''
18 {% if hidden_tags %}
18 {% if hidden_tags %}
19 <p>{% trans 'Hidden tags:' %}
19 <p>{% trans 'Hidden tags:' %}
20 {% for tag in hidden_tags %}
20 {% for tag in hidden_tags %}
21 <a class="tag" href="{% url 'tag' tag.name %}">
21 {% autoescape off %}
22 #{{ tag.name }}</a>{% if not forloop.last %},{% endif %}
22 {{ tag.get_view }}
23 {% endautoescape %}
23 {% endfor %}
24 {% endfor %}
24 </p>
25 </p>
25 {% else %}
26 {% else %}
@@ -17,7 +17,7 b''
17
17
18 <div class="image-mode-tab">
18 <div class="image-mode-tab">
19 <a class="current_mode" href="{% url 'thread' opening_post.id %}">{% trans 'Normal mode' %}</a>,
19 <a class="current_mode" href="{% url 'thread' opening_post.id %}">{% trans 'Normal mode' %}</a>,
20 <a href="{% url 'thread_mode' opening_post.id 'gallery' %}">{% trans 'Gallery mode' %}</a>
20 <a href="{% url 'thread_gallery' opening_post.id %}">{% trans 'Gallery mode' %}</a>
21 </div>
21 </div>
22
22
23 {% if bumpable %}
23 {% if bumpable %}
@@ -34,30 +34,27 b''
34 {% with can_bump=thread.can_bump %}
34 {% with can_bump=thread.can_bump %}
35 {% for post in thread.get_replies %}
35 {% for post in thread.get_replies %}
36 {% with is_opening=forloop.first %}
36 {% with is_opening=forloop.first %}
37 {% post_view post moderator=moderator is_opening=is_opening thread=thread bumpable=can_bump opening_post_id=opening_post.id %}
37 {% post_view post moderator=moderator is_opening=is_opening bumpable=can_bump opening_post_id=opening_post.id %}
38 {% endwith %}
38 {% endwith %}
39 {% endfor %}
39 {% endfor %}
40 {% endwith %}
40 {% endwith %}
41 </div>
41 </div>
42
42
43 {% if not thread.archived %}
43 {% if not thread.archived %}
44 <div class="post-form-w" id="form">
44 <div class="post-form-w">
45 <script src="{% static 'js/panel.js' %}"></script>
45 <script src="{% static 'js/panel.js' %}"></script>
46 <div class="form-title">{% trans "Reply to thread" %} #{{ opening_post.id }}</div>
46 <div class="form-title">{% trans "Reply to thread" %} #{{ opening_post.id }}</div>
47 <div class="post-form" id="compact-form">
47 <div class="post-form" id="compact-form">
48 <div class="swappable-form-full">
48 <div class="swappable-form-full">
49 <form enctype="multipart/form-data" method="post"
49 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
50 >{% csrf_token %}
51 <div class="compact-form-text"></div>
50 <div class="compact-form-text"></div>
52 {{ form.as_div }}
51 {{ form.as_div }}
53 <div class="form-submit">
52 <div class="form-submit">
54 <input type="submit" value="{% trans "Post" %}"/>
53 <input type="submit" value="{% trans "Post" %}"/>
55 </div>
54 </div>
55 (ctrl-enter)
56 </form>
56 </form>
57 </div>
57 </div>
58 <a onclick="swapForm(); return false;" href="#">
59 {% trans 'Switch mode' %}
60 </a>
61 <div><a href="{% url "staticpage" name="help" %}">
58 <div><a href="{% url "staticpage" name="help" %}">
62 {% trans 'Text syntax' %}</a></div>
59 {% trans 'Text syntax' %}</a></div>
63 </div>
60 </div>
@@ -85,10 +82,10 b''
85 data-ws-host="{{ ws_host }}"
82 data-ws-host="{{ ws_host }}"
86 data-ws-port="{{ ws_port }}">
83 data-ws-port="{{ ws_port }}">
87 {% cache 600 thread_meta thread.last_edit_time moderator LANGUAGE_CODE %}
84 {% cache 600 thread_meta thread.last_edit_time moderator LANGUAGE_CODE %}
88 <span id="autoupdate">[-]</span>
85 <button id="autoupdate">{% trans 'Update' %}</button>
89 <span id="reply-count">{{ thread.get_reply_count }}</span>/{{ max_replies }} {% trans 'messages' %},
86 <span id="reply-count">{{ thread.get_reply_count }}</span>/{{ max_replies }} {% trans 'messages' %},
90 <span id="image-count">{{ thread.get_images_count }}</span> {% trans 'images' %}.
87 <span id="image-count">{{ thread.get_images_count }}</span> {% trans 'images' %}.
91 {% trans 'Last update: ' %}<span id="last-update">{{ thread.last_edit_time }}</span>
88 {% trans 'Last update: ' %}<span id="last-update">{{ thread.last_edit_time|date:'r' }}</span>
92 [<a href="rss/">RSS</a>]
89 [<a href="rss/">RSS</a>]
93 {% endcache %}
90 {% endcache %}
94 </span>
91 </span>
@@ -17,7 +17,7 b''
17 {% cache 600 thread_gallery_view thread.id thread.last_edit_time LANGUAGE_CODE request.get_host %}
17 {% cache 600 thread_gallery_view thread.id thread.last_edit_time LANGUAGE_CODE request.get_host %}
18 <div class="image-mode-tab">
18 <div class="image-mode-tab">
19 <a href="{% url 'thread' thread.get_opening_post.id %}">{% trans 'Normal mode' %}</a>,
19 <a href="{% url 'thread' thread.get_opening_post.id %}">{% trans 'Normal mode' %}</a>,
20 <a class="current_mode" href="{% url 'thread_mode' thread.get_opening_post.id 'gallery' %}">{% trans 'Gallery mode' %}</a>
20 <a class="current_mode" href="{% url 'thread_gallery' thread.get_opening_post.id %}">{% trans 'Gallery mode' %}</a>
21 </div>
21 </div>
22
22
23 <div id="posts-table">
23 <div id="posts-table">
@@ -54,11 +54,11 b''
54 {% get_current_language as LANGUAGE_CODE %}
54 {% get_current_language as LANGUAGE_CODE %}
55
55
56 <span class="metapanel" data-last-update="{{ last_update }}">
56 <span class="metapanel" data-last-update="{{ last_update }}">
57 {% cache 600 thread_meta thread.last_edit_time moderator LANGUAGE_CODE %}
57 {% cache 600 thread_gallery_meta thread.last_edit_time moderator LANGUAGE_CODE %}
58 <span id="reply-count">{{ thread.get_reply_count }}</span>/{{ max_replies }}
58 <span id="reply-count">{{ thread.get_reply_count }}</span>/{{ max_replies }}
59 {% trans 'messages' %},
59 {% trans 'messages' %},
60 <span id="image-count">{{ thread.get_images_count }}</span> {% trans 'images' %}.
60 <span id="image-count">{{ thread.get_images_count }}</span> {% trans 'images' %}.
61 {% trans 'Last update: ' %}{{ thread.last_edit_time }}
61 {% trans 'Last update: ' %}{{ thread.last_edit_time|date:'r' }}
62 [<a href="rss/">RSS</a>]
62 [<a href="rss/">RSS</a>]
63 {% endcache %}
63 {% endcache %}
64 </span>
64 </span>
@@ -1,6 +1,12 b''
1 import re
1 from django.shortcuts import get_object_or_404
2 from django.shortcuts import get_object_or_404
2 from django import template
3 from django import template
3
4
5 ELLIPSIZER = '...'
6
7 REGEX_LINES = re.compile(r'(<div class="br"></div>)', re.U | re.S)
8 REGEX_TAG = re.compile(r'<(/)?([^ ]+?)(?:(\s*/)| .*?)?>', re.S)
9
4
10
5 register = template.Library()
11 register = template.Library()
6
12
@@ -25,18 +31,6 b' def post_url(*args, **kwargs):'
25 return post.get_url()
31 return post.get_url()
26
32
27
33
28 @register.simple_tag(name='post_object_url')
29 def post_object_url(*args, **kwargs):
30 post = args[0]
31
32 if 'thread' in kwargs:
33 post_thread = kwargs['thread']
34 else:
35 post_thread = None
36
37 return post.get_url(thread=post_thread)
38
39
40 @register.simple_tag(name='image_actions')
34 @register.simple_tag(name='image_actions')
41 def image_actions(*args, **kwargs):
35 def image_actions(*args, **kwargs):
42 image_link = args[0]
36 image_link = args[0]
@@ -65,10 +59,7 b' def post_view(post, moderator=False, nee'
65 else:
59 else:
66 is_opening = post.is_opening()
60 is_opening = post.is_opening()
67
61
68 if 'thread' in kwargs:
62 thread = post.get_thread()
69 thread = kwargs['thread']
70 else:
71 thread = post.get_thread()
72
63
73 if 'can_bump' in kwargs:
64 if 'can_bump' in kwargs:
74 can_bump = kwargs['can_bump']
65 can_bump = kwargs['can_bump']
@@ -87,3 +78,68 b' def post_view(post, moderator=False, nee'
87 'truncated': truncated,
78 'truncated': truncated,
88 'opening_post_id': opening_post_id,
79 'opening_post_id': opening_post_id,
89 }
80 }
81
82
83 @register.filter(is_safe=True)
84 def truncate_lines(text, length):
85 if length <= 0:
86 return ''
87
88 html4_singlets = (
89 'br', 'col', 'link', 'base', 'img',
90 'param', 'area', 'hr', 'input'
91 )
92
93 # Count non-HTML chars/words and keep note of open tags
94 pos = 0
95 end_text_pos = 0
96 current_len = 0
97 open_tags = []
98
99 while current_len <= length:
100 m = REGEX_LINES.search(text, pos)
101 if not m:
102 # Checked through whole string
103 break
104 pos = m.end(0)
105 if m.group(1):
106 # It's an actual non-HTML word or char
107 current_len += 1
108 if current_len == length:
109 end_text_pos = m.start(0)
110 continue
111 # Check for tag
112 tag = REGEX_TAG.match(m.group(0))
113 if not tag or current_len >= length:
114 # Don't worry about non tags or tags after our truncate point
115 continue
116 closing_tag, tagname, self_closing = tag.groups()
117 # Element names are always case-insensitive
118 tagname = tagname.lower()
119 if self_closing or tagname in html4_singlets:
120 pass
121 elif closing_tag:
122 # Check for match in open tags list
123 try:
124 i = open_tags.index(tagname)
125 except ValueError:
126 pass
127 else:
128 # SGML: An end tag closes, back to the matching start tag,
129 # all unclosed intervening start tags with omitted end tags
130 open_tags = open_tags[i + 1:]
131 else:
132 # Add it to the start of the open tags list
133 open_tags.insert(0, tagname)
134
135 if current_len <= length:
136 return text
137 out = text[:end_text_pos]
138
139 if not out.endswith(ELLIPSIZER):
140 out += ELLIPSIZER
141 # Close any tags still open
142 for tag in open_tags:
143 out += '</%s>' % tag
144 # Return string
145 return out
@@ -1,11 +1,11 b''
1 import simplejson
1 import simplejson
2
2
3 from django.test import TestCase
3 from django.test import TestCase
4 from boards.views import api
4
5
5 from boards.models import Tag, Post
6 from boards.models import Tag, Post
6 from boards.tests.mocks import MockRequest
7 from boards.tests.mocks import MockRequest
7 from boards.utils import datetime_to_epoch
8 from boards.utils import datetime_to_epoch
8 from boards.views.api import api_get_threaddiff
9
9
10
10
11 class ApiTest(TestCase):
11 class ApiTest(TestCase):
@@ -17,9 +17,8 b' class ApiTest(TestCase):'
17 last_edit_time = datetime_to_epoch(opening_post.last_edit_time)
17 last_edit_time = datetime_to_epoch(opening_post.last_edit_time)
18
18
19 # Check the exact timestamp post was added
19 # Check the exact timestamp post was added
20 empty_response = api_get_threaddiff(MockRequest(),
20 empty_response = api.api_get_threaddiff(
21 str(opening_post.thread_new.id),
21 MockRequest(), str(opening_post.get_thread().id), str(last_edit_time))
22 str(last_edit_time))
23 diff = simplejson.loads(empty_response.content)
22 diff = simplejson.loads(empty_response.content)
24 self.assertEqual(0, len(diff['added']),
23 self.assertEqual(0, len(diff['added']),
25 'There must be no added posts in the diff.')
24 'There must be no added posts in the diff.')
@@ -28,23 +27,43 b' class ApiTest(TestCase):'
28
27
29 reply = Post.objects.create_post(title='',
28 reply = Post.objects.create_post(title='',
30 text='[post]%d[/post]\ntext' % opening_post.id,
29 text='[post]%d[/post]\ntext' % opening_post.id,
31 thread=opening_post.thread_new)
30 thread=opening_post.get_thread())
32
31
33 # Check the timestamp before post was added
32 # Check the timestamp before post was added
34 response = api_get_threaddiff(MockRequest(),
33 response = api.api_get_threaddiff(
35 str(opening_post.thread_new.id),
34 MockRequest(), str(opening_post.get_thread().id), str(last_edit_time))
36 str(last_edit_time))
37 diff = simplejson.loads(response.content)
35 diff = simplejson.loads(response.content)
38 self.assertEqual(1, len(diff['added']),
36 self.assertEqual(1, len(diff['added']),
39 'There must be 1 added posts in the diff.')
37 'There must be 1 added posts in the diff.')
40 self.assertEqual(1, len(diff['updated']),
38 self.assertEqual(1, len(diff['updated']),
41 'There must be 1 updated posts in the diff.')
39 'There must be 1 updated posts in the diff.')
42
40
43 empty_response = api_get_threaddiff(MockRequest(),
41 empty_response = api.api_get_threaddiff(MockRequest(),
44 str(opening_post.thread_new.id),
42 str(opening_post.get_thread().id),
45 str(datetime_to_epoch(reply.last_edit_time)))
43 str(datetime_to_epoch(reply.last_edit_time)))
46 diff = simplejson.loads(empty_response.content)
44 diff = simplejson.loads(empty_response.content)
47 self.assertEqual(0, len(diff['added']),
45 self.assertEqual(0, len(diff['added']),
48 'There must be no added posts in the diff.')
46 'There must be no added posts in the diff.')
49 self.assertEqual(0, len(diff['updated']),
47 self.assertEqual(0, len(diff['updated']),
50 'There must be no updated posts in the diff.') No newline at end of file
48 'There must be no updated posts in the diff.')
49
50 def test_get_threads(self):
51 # Create 10 threads
52 tag = Tag.objects.create(name='test_tag')
53 for i in range(5):
54 Post.objects.create_post(title='title', text='text', tags=[tag])
55
56 # Get all threads
57 response = api.api_get_threads(MockRequest(), 5)
58 diff = simplejson.loads(response.content)
59 self.assertEqual(5, len(diff), 'Invalid thread list response.')
60
61 # Get less threads then exist
62 response = api.api_get_threads(MockRequest(), 3)
63 diff = simplejson.loads(response.content)
64 self.assertEqual(3, len(diff), 'Invalid thread list response.')
65
66 # Get more threads then exist
67 response = api.api_get_threads(MockRequest(), 10)
68 diff = simplejson.loads(response.content)
69 self.assertEqual(5, len(diff), 'Invalid thread list response.')
@@ -25,3 +25,10 b' class ParserTest(TestCase):'
25 self.assertEqual('[post]12[/post]\nText',
25 self.assertEqual('[post]12[/post]\nText',
26 preparsed_text, 'Reflink not preparsed.')
26 preparsed_text, 'Reflink not preparsed.')
27
27
28 def preparse_user(self):
29 raw_text = '@user\nuser@example.com\n@user\nuser @user'
30 preparsed_text = Post.objects._preparse_text(raw_text)
31
32 self.assertEqual('[user]user[/user]\nuser@example.com\n[user]user[/user]\nuser [user]user[/user]',
33 preparsed_text, 'User link not preparsed.')
34
@@ -7,7 +7,7 b' from boards.models import Tag, Post, Thr'
7 class PostTests(TestCase):
7 class PostTests(TestCase):
8
8
9 def _create_post(self):
9 def _create_post(self):
10 tag = Tag.objects.create(name='test_tag')
10 tag, created = Tag.objects.get_or_create(name='test_tag')
11 return Post.objects.create_post(title='title', text='text',
11 return Post.objects.create_post(title='title', text='text',
12 tags=[tag])
12 tags=[tag])
13
13
@@ -37,7 +37,7 b' class PostTests(TestCase):'
37 thread = opening_post.get_thread()
37 thread = opening_post.get_thread()
38 reply = Post.objects.create_post("", "", thread=thread)
38 reply = Post.objects.create_post("", "", thread=thread)
39
39
40 opening_post.delete()
40 thread.delete()
41
41
42 self.assertFalse(Post.objects.filter(id=reply.id).exists(),
42 self.assertFalse(Post.objects.filter(id=reply.id).exists(),
43 'Reply was not deleted with the thread.')
43 'Reply was not deleted with the thread.')
@@ -76,7 +76,7 b' class PostTests(TestCase):'
76
76
77 thread = opening_post.get_thread()
77 thread = opening_post.get_thread()
78
78
79 self.assertEqual(3, thread.replies.count())
79 self.assertEqual(3, thread.get_replies().count())
80
80
81 def test_create_post_with_tag(self):
81 def test_create_post_with_tag(self):
82 """Test adding tag to post"""
82 """Test adding tag to post"""
@@ -6,6 +6,7 b' from boards.views import api, tag_thread'
6 settings, all_tags
6 settings, all_tags
7 from boards.views.authors import AuthorsView
7 from boards.views.authors import AuthorsView
8 from boards.views.ban import BanUserView
8 from boards.views.ban import BanUserView
9 from boards.views.notifications import NotificationView
9 from boards.views.search import BoardSearchView
10 from boards.views.search import BoardSearchView
10 from boards.views.static import StaticPageView
11 from boards.views.static import StaticPageView
11 from boards.views.preview import PostPreviewView
12 from boards.views.preview import PostPreviewView
@@ -30,10 +31,10 b" urlpatterns = patterns('',"
30 tag_threads.TagView.as_view(), name='tag'),
31 tag_threads.TagView.as_view(), name='tag'),
31
32
32 # /boards/thread/
33 # /boards/thread/
33 url(r'^thread/(?P<post_id>\w+)/$', views.thread.ThreadView.as_view(),
34 url(r'^thread/(?P<post_id>\w+)/$', views.thread.normal.NormalThreadView.as_view(),
34 name='thread'),
35 name='thread'),
35 url(r'^thread/(?P<post_id>\w+)/mode/(?P<mode>\w+)/$', views.thread.ThreadView
36 url(r'^thread/(?P<post_id>\w+)/mode/gallery/$', views.thread.gallery.GalleryThreadView.as_view(),
36 .as_view(), name='thread_mode'),
37 name='thread_gallery'),
37
38
38 url(r'^settings/$', settings.SettingsView.as_view(), name='settings'),
39 url(r'^settings/$', settings.SettingsView.as_view(), name='settings'),
39 url(r'^tags/$', all_tags.AllTagsView.as_view(), name='tags'),
40 url(r'^tags/$', all_tags.AllTagsView.as_view(), name='tags'),
@@ -66,10 +67,15 b" urlpatterns = patterns('',"
66 name='get_thread'),
67 name='get_thread'),
67 url(r'^api/add_post/(?P<opening_post_id>\w+)/$', api.api_add_post,
68 url(r'^api/add_post/(?P<opening_post_id>\w+)/$', api.api_add_post,
68 name='add_post'),
69 name='add_post'),
70 url(r'^api/notifications/(?P<username>\w+)/$', api.api_get_notifications,
71 name='api_notifications'),
69
72
70 # Search
73 # Search
71 url(r'^search/$', BoardSearchView.as_view(), name='search'),
74 url(r'^search/$', BoardSearchView.as_view(), name='search'),
72
75
76 # Notifications
77 url(r'^notifications/(?P<username>\w+)$', NotificationView.as_view(), name='notifications'),
78
73 # Post preview
79 # Post preview
74 url(r'^preview/$', PostPreviewView.as_view(), name='preview'),
80 url(r'^preview/$', PostPreviewView.as_view(), name='preview'),
75
81
@@ -3,6 +3,8 b' This module contains helper functions an'
3 """
3 """
4 import time
4 import time
5 import hmac
5 import hmac
6 from django.core.cache import cache
7 from django.db.models import Model
6
8
7 from django.utils import timezone
9 from django.utils import timezone
8
10
@@ -23,6 +25,7 b' def get_client_ip(request):'
23 return ip
25 return ip
24
26
25
27
28 # TODO The output format is not epoch because it includes microseconds
26 def datetime_to_epoch(datetime):
29 def datetime_to_epoch(datetime):
27 return int(time.mktime(timezone.localtime(
30 return int(time.mktime(timezone.localtime(
28 datetime,timezone.get_current_timezone()).timetuple())
31 datetime,timezone.get_current_timezone()).timetuple())
@@ -40,4 +43,27 b" def get_websocket_token(user_id='', time"
40 sign.update(timestamp.encode())
43 sign.update(timestamp.encode())
41 token = sign.hexdigest()
44 token = sign.hexdigest()
42
45
43 return token No newline at end of file
46 return token
47
48
49 def cached_result(function):
50 """
51 Caches method result in the Django's cache system, persisted by object name,
52 object name and model id if object is a Django model.
53 """
54 def inner_func(obj, *args, **kwargs):
55 # TODO Include method arguments to the cache key
56 cache_key = obj.__class__.__name__ + '_' + function.__name__
57 if isinstance(obj, Model):
58 cache_key += '_' + str(obj.id)
59
60 persisted_result = cache.get(cache_key)
61 if persisted_result:
62 result = persisted_result
63 else:
64 result = function(obj, *args, **kwargs)
65 cache.set(cache_key, result)
66
67 return result
68
69 return inner_func
@@ -1,11 +1,16 b''
1 from django.core.files import File
2 from django.core.files.temp import NamedTemporaryFile
3 from django.core.paginator import EmptyPage
1 from django.db import transaction
4 from django.db import transaction
5 from django.http import Http404
2 from django.shortcuts import render, redirect
6 from django.shortcuts import render, redirect
7 import requests
3
8
4 from boards import utils, settings
9 from boards import utils, settings
5 from boards.abstracts.paginator import get_paginator
10 from boards.abstracts.paginator import get_paginator
6 from boards.abstracts.settingsmanager import get_settings_manager
11 from boards.abstracts.settingsmanager import get_settings_manager
7 from boards.forms import ThreadForm, PlainErrorList
12 from boards.forms import ThreadForm, PlainErrorList
8 from boards.models import Post, Thread, Ban, Tag
13 from boards.models import Post, Thread, Ban, Tag, PostImage
9 from boards.views.banned import BannedView
14 from boards.views.banned import BannedView
10 from boards.views.base import BaseBoardView, CONTEXT_FORM
15 from boards.views.base import BaseBoardView, CONTEXT_FORM
11 from boards.views.posting_mixin import PostMixin
16 from boards.views.posting_mixin import PostMixin
@@ -32,7 +37,7 b' class AllThreadsView(PostMixin, BaseBoar'
32 self.settings_manager = None
37 self.settings_manager = None
33 super(AllThreadsView, self).__init__()
38 super(AllThreadsView, self).__init__()
34
39
35 def get(self, request, page=DEFAULT_PAGE, form=None):
40 def get(self, request, page=DEFAULT_PAGE, form: ThreadForm=None):
36 params = self.get_context_data(request=request)
41 params = self.get_context_data(request=request)
37
42
38 if not form:
43 if not form:
@@ -43,7 +48,10 b' class AllThreadsView(PostMixin, BaseBoar'
43 settings.THREADS_PER_PAGE)
48 settings.THREADS_PER_PAGE)
44 paginator.current_page = int(page)
49 paginator.current_page = int(page)
45
50
46 threads = paginator.page(page).object_list
51 try:
52 threads = paginator.page(page).object_list
53 except EmptyPage:
54 raise Http404()
47
55
48 params[PARAMETER_THREADS] = threads
56 params[PARAMETER_THREADS] = threads
49 params[CONTEXT_FORM] = form
57 params[CONTEXT_FORM] = form
@@ -92,7 +100,7 b' class AllThreadsView(PostMixin, BaseBoar'
92 return tags
100 return tags
93
101
94 @transaction.atomic
102 @transaction.atomic
95 def create_thread(self, request, form, html_response=True):
103 def create_thread(self, request, form: ThreadForm, html_response=True):
96 """
104 """
97 Creates a new thread with an opening post.
105 Creates a new thread with an opening post.
98 """
106 """
@@ -110,7 +118,7 b' class AllThreadsView(PostMixin, BaseBoar'
110
118
111 title = data[FORM_TITLE]
119 title = data[FORM_TITLE]
112 text = data[FORM_TEXT]
120 text = data[FORM_TEXT]
113 image = data.get(FORM_IMAGE)
121 image = form.get_image()
114
122
115 text = self._remove_invalid_links(text)
123 text = self._remove_invalid_links(text)
116
124
@@ -133,5 +141,5 b' class AllThreadsView(PostMixin, BaseBoar'
133 Gets list of threads that will be shown on a page.
141 Gets list of threads that will be shown on a page.
134 """
142 """
135
143
136 return Thread.objects.all().order_by('-bump_time')\
144 return Thread.objects.order_by('-bump_time')\
137 .exclude(tags__in=self.settings_manager.get_hidden_tags())
145 .exclude(tags__in=self.settings_manager.get_hidden_tags())
@@ -12,6 +12,7 b' from boards.forms import PostForm, Plain'
12 from boards.models import Post, Thread, Tag
12 from boards.models import Post, Thread, Tag
13 from boards.utils import datetime_to_epoch
13 from boards.utils import datetime_to_epoch
14 from boards.views.thread import ThreadView
14 from boards.views.thread import ThreadView
15 from boards.models.user import Notification
15
16
16 __author__ = 'neko259'
17 __author__ = 'neko259'
17
18
@@ -48,10 +49,10 b' def api_get_threaddiff(request, thread_i'
48 'updated': [],
49 'updated': [],
49 'last_update': None,
50 'last_update': None,
50 }
51 }
51 added_posts = Post.objects.filter(thread_new=thread,
52 added_posts = Post.objects.filter(threads__in=[thread],
52 pub_time__gt=filter_time) \
53 pub_time__gt=filter_time) \
53 .order_by('pub_time')
54 .order_by('pub_time')
54 updated_posts = Post.objects.filter(thread_new=thread,
55 updated_posts = Post.objects.filter(threads__in=[thread],
55 pub_time__lte=filter_time,
56 pub_time__lte=filter_time,
56 last_edit_time__gt=filter_time)
57 last_edit_time__gt=filter_time)
57
58
@@ -122,7 +123,6 b' def get_post(request, post_id):'
122 return render(request, 'boards/api_post.html', context_instance=context)
123 return render(request, 'boards/api_post.html', context_instance=context)
123
124
124
125
125 # TODO Test this
126 def api_get_threads(request, count):
126 def api_get_threads(request, count):
127 """
127 """
128 Gets the JSON thread opening posts list.
128 Gets the JSON thread opening posts list.
@@ -152,8 +152,11 b' def api_get_threads(request, count):'
152 opening_post = thread.get_opening_post()
152 opening_post = thread.get_opening_post()
153
153
154 # TODO Add tags, replies and images count
154 # TODO Add tags, replies and images count
155 opening_posts.append(get_post_data(opening_post.id,
155 post_data = get_post_data(opening_post.id, include_last_update=True)
156 include_last_update=True))
156 post_data['bumpable'] = thread.can_bump()
157 post_data['archived'] = thread.archived
158
159 opening_posts.append(post_data)
157
160
158 return HttpResponse(content=json.dumps(opening_posts))
161 return HttpResponse(content=json.dumps(opening_posts))
159
162
@@ -199,6 +202,20 b' def api_get_thread_posts(request, openin'
199 return HttpResponse(content=json.dumps(json_data))
202 return HttpResponse(content=json.dumps(json_data))
200
203
201
204
205 def api_get_notifications(request, username):
206 last_notification_id_str = request.GET.get('last', None)
207 last_id = int(last_notification_id_str) if last_notification_id_str is not None else None
208
209 posts = Notification.objects.get_notification_posts(username=username,
210 last=last_id)
211
212 json_post_list = []
213 for post in posts:
214 json_post_list.append(get_post_data(post.id))
215 return HttpResponse(content=json.dumps(json_post_list))
216
217
218
202 def api_get_post(request, post_id):
219 def api_get_post(request, post_id):
203 """
220 """
204 Gets the JSON of a post. This can be
221 Gets the JSON of a post. This can be
@@ -1,5 +1,4 b''
1 from django.db import transaction
1 from django.db import transaction
2 from django.template import RequestContext
3 from django.views.generic import View
2 from django.views.generic import View
4
3
5 from boards import utils
4 from boards import utils
@@ -1,31 +1,37 b''
1 from django.db import transaction
1 from django.db import transaction
2 from django.shortcuts import render, redirect
2 from django.shortcuts import render, redirect
3
3
4 from boards.abstracts.settingsmanager import get_settings_manager
4 from boards.abstracts.settingsmanager import get_settings_manager, \
5 SETTING_USERNAME, SETTING_LAST_NOTIFICATION_ID
5 from boards.views.base import BaseBoardView, CONTEXT_FORM
6 from boards.views.base import BaseBoardView, CONTEXT_FORM
6 from boards.forms import SettingsForm, PlainErrorList
7 from boards.forms import SettingsForm, PlainErrorList
7
8
8 FORM_THEME = 'theme'
9 FORM_THEME = 'theme'
10 FORM_USERNAME = 'username'
9
11
10 CONTEXT_HIDDEN_TAGS = 'hidden_tags'
12 CONTEXT_HIDDEN_TAGS = 'hidden_tags'
11
13
14 TEMPLATE = 'boards/settings.html'
15
12
16
13 class SettingsView(BaseBoardView):
17 class SettingsView(BaseBoardView):
14
18
15 def get(self, request):
19 def get(self, request):
16 params = self.get_context_data()
20 params = dict()
17 settings_manager = get_settings_manager(request)
21 settings_manager = get_settings_manager(request)
18
22
19 selected_theme = settings_manager.get_theme()
23 selected_theme = settings_manager.get_theme()
20
24
21 form = SettingsForm(initial={FORM_THEME: selected_theme},
25 form = SettingsForm(
22 error_class=PlainErrorList)
26 initial={
27 FORM_THEME: selected_theme,
28 FORM_USERNAME: settings_manager.get_setting(SETTING_USERNAME)},
29 error_class=PlainErrorList)
23
30
24 params[CONTEXT_FORM] = form
31 params[CONTEXT_FORM] = form
25 params[CONTEXT_HIDDEN_TAGS] = settings_manager.get_hidden_tags()
32 params[CONTEXT_HIDDEN_TAGS] = settings_manager.get_hidden_tags()
26
33
27 # TODO Use dict here
34 return render(request, TEMPLATE, params)
28 return render(request, 'boards/settings.html', params)
29
35
30 def post(self, request):
36 def post(self, request):
31 settings_manager = get_settings_manager(request)
37 settings_manager = get_settings_manager(request)
@@ -35,7 +41,19 b' class SettingsView(BaseBoardView):'
35
41
36 if form.is_valid():
42 if form.is_valid():
37 selected_theme = form.cleaned_data[FORM_THEME]
43 selected_theme = form.cleaned_data[FORM_THEME]
44 username = form.cleaned_data[FORM_USERNAME].lower()
38
45
39 settings_manager.set_theme(selected_theme)
46 settings_manager.set_theme(selected_theme)
40
47
41 return redirect('settings')
48 settings_manager.set_setting(SETTING_USERNAME, username)
49 settings_manager.set_setting(SETTING_LAST_NOTIFICATION_ID, None)
50
51 return redirect('settings')
52 else:
53 params = dict()
54
55 params[CONTEXT_FORM] = form
56 params[CONTEXT_HIDDEN_TAGS] = settings_manager.get_hidden_tags()
57
58 return render(request, TEMPLATE, params)
59
@@ -1,14 +1,16 b''
1 from django.shortcuts import get_object_or_404
1 from django.shortcuts import get_object_or_404
2
2
3 from boards.abstracts.settingsmanager import get_settings_manager
3 from boards.abstracts.settingsmanager import get_settings_manager, \
4 SETTING_FAVORITE_TAGS, SETTING_HIDDEN_TAGS
4 from boards.models import Tag, Thread
5 from boards.models import Tag, Thread
5 from boards.views.all_threads import AllThreadsView, DEFAULT_PAGE
6 from boards.views.all_threads import AllThreadsView, DEFAULT_PAGE
6 from boards.views.mixins import DispatcherMixin, RedirectNextMixin
7 from boards.views.mixins import DispatcherMixin, RedirectNextMixin
7 from boards.forms import ThreadForm, PlainErrorList
8 from boards.forms import ThreadForm, PlainErrorList
8
9
9 PARAM_HIDDEN_TAGS = 'hidden_tags'
10 PARAM_HIDDEN_TAGS = 'hidden_tags'
10 PARAM_FAV_TAGS = 'fav_tags'
11 PARAM_TAG = 'tag'
11 PARAM_TAG = 'tag'
12 PARAM_IS_FAVORITE = 'is_favorite'
13 PARAM_IS_HIDDEN = 'is_hidden'
12
14
13 __author__ = 'neko259'
15 __author__ = 'neko259'
14
16
@@ -30,8 +32,11 b' class TagView(AllThreadsView, Dispatcher'
30 tag = get_object_or_404(Tag, name=self.tag_name)
32 tag = get_object_or_404(Tag, name=self.tag_name)
31 params[PARAM_TAG] = tag
33 params[PARAM_TAG] = tag
32
34
33 params[PARAM_FAV_TAGS] = settings_manager.get_fav_tags()
35 fav_tag_names = settings_manager.get_setting(SETTING_FAVORITE_TAGS)
34 params[PARAM_HIDDEN_TAGS] = settings_manager.get_hidden_tags()
36 hidden_tag_names = settings_manager.get_setting(SETTING_HIDDEN_TAGS)
37
38 params[PARAM_IS_FAVORITE] = fav_tag_names is not None and tag.name in fav_tag_names
39 params[PARAM_IS_HIDDEN] = hidden_tag_names is not None and tag.name in hidden_tag_names
35
40
36 return params
41 return params
37
42
@@ -41,3 +41,37 b' images to a post'
41 * Changed markdown to bbcode
41 * Changed markdown to bbcode
42 * Removed linked tags
42 * Removed linked tags
43 * [ADMIN] Added title to the post logs to make them more informative
43 * [ADMIN] Added title to the post logs to make them more informative
44
45 # 2.2
46 * Support websockets for thread update
47 * Using div as line separator
48 * CSS and JS compressor
49
50 # 2.2.1
51 * Changed logs style
52 * Text preparsing. Support markdown-style text that parses into bbcode
53 * "bumpable" field for threads. If the thread became non-bumpable, it will
54 remain in this state even if the bumplimit changes or posts will be deleted from
55 it
56
57 # 2.2.4
58 * Default settings. There is a global neboard default settings file, but user
59 can override settings in the old settings.py
60 * Required tags. Some tags can be marked as required and you can't create thread
61 without at least one of them, while you can add any tags you want to it
62 * [ADMIN] Cosmetic changes in the admin site. Adding and removing tags is much
63 more simple now
64 * Don't save tag's threads as a separate table, use aggregation instead
65
66 # 2.3.0 Neiro
67 * Image deduplication
68
69 # 2.4.0 Korra
70 * Downloading images by URL
71 * [CODE] Cached properties
72
73 # 2.5.0 Yasako
74 * User notifications
75 * Posting to many threads by one post
76 * Tag details
77 * Removed compact form
@@ -45,6 +45,15 b' format. 2 formats are available: ``html`'
45 * ``updated``: list of updated posts
45 * ``updated``: list of updated posts
46 * ``last_update``: last update timestamp
46 * ``last_update``: last update timestamp
47
47
48 ## Notifications ##
49
50 /api/notifications/<username>/[?last=<id>]
51
52 Get user notifications for user starting from the post ID.
53
54 * ``username``: name of the notified user
55 * ``id``: ID of a last notification post
56
48 ## General info ##
57 ## General info ##
49
58
50 In case of incorrect request you can get http error 404.
59 In case of incorrect request you can get http error 404.
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
General Comments 0
You need to be logged in to leave comments. Login now