##// END OF EJS Templates
Merged with default branch
neko259 -
r1360:94773499 merge decentral
parent child Browse files
Show More
@@ -0,0 +1,20 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', '0025_auto_20150825_2049'),
11 ]
12
13 operations = [
14 migrations.AddField(
15 model_name='post',
16 name='opening',
17 field=models.BooleanField(default=False),
18 preserve_default=False,
19 ),
20 ]
@@ -0,0 +1,22 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import migrations
5
6
7 class Migration(migrations.Migration):
8
9 def build_opening_flag(apps, schema_editor):
10 Post = apps.get_model('boards', 'Post')
11 for post in Post.objects.all():
12 op = Post.objects.filter(threads__in=[post.thread]).order_by('pub_time').first()
13 post.opening = op.id == post.id
14 post.save(update_fields=['opening'])
15
16 dependencies = [
17 ('boards', '0026_post_opening'),
18 ]
19
20 operations = [
21 migrations.RunPython(build_opening_flag),
22 ]
@@ -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', '0027_auto_20150912_1632'),
11 ]
12
13 operations = [
14 migrations.AlterField(
15 model_name='post',
16 name='threads',
17 field=models.ManyToManyField(to='boards.Thread', related_name='multi_replies', db_index=True),
18 ),
19 ]
@@ -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', '0028_auto_20150928_2211'),
11 ]
12
13 operations = [
14 migrations.AddField(
15 model_name='tag',
16 name='parent',
17 field=models.ForeignKey(to='boards.Tag', null=True),
18 ),
19 ]
@@ -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', '0029_tag_parent'),
11 ]
12
13 operations = [
14 migrations.AlterField(
15 model_name='tag',
16 name='parent',
17 field=models.ForeignKey(related_name='children', null=True, to='boards.Tag'),
18 ),
19 ]
@@ -0,0 +1,15 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', '0030_auto_20150929_1816'),
11 ('boards', '0026_auto_20150830_2006'),
12 ]
13
14 operations = [
15 ]
@@ -0,0 +1,66 b''
1 import os
2 import re
3
4 from django.core.files.uploadedfile import SimpleUploadedFile
5 from pytube import YouTube
6 import requests
7
8 from boards.utils import validate_file_size
9
10 YOUTUBE_VIDEO_FORMAT = 'webm'
11
12 HTTP_RESULT_OK = 200
13
14 HEADER_CONTENT_LENGTH = 'content-length'
15 HEADER_CONTENT_TYPE = 'content-type'
16
17 FILE_DOWNLOAD_CHUNK_BYTES = 100000
18
19 YOUTUBE_URL = re.compile(r'https?://www\.youtube\.com/watch\?v=\w+')
20
21
22 class Downloader:
23 @staticmethod
24 def handles(url: str) -> bool:
25 return False
26
27 @staticmethod
28 def download(url: str):
29 # Verify content headers
30 response_head = requests.head(url, verify=False)
31 content_type = response_head.headers[HEADER_CONTENT_TYPE].split(';')[0]
32 length_header = response_head.headers.get(HEADER_CONTENT_LENGTH)
33 if length_header:
34 length = int(length_header)
35 validate_file_size(length)
36 # Get the actual content into memory
37 response = requests.get(url, verify=False, stream=True)
38
39 # Download file, stop if the size exceeds limit
40 size = 0
41 content = b''
42 for chunk in response.iter_content(FILE_DOWNLOAD_CHUNK_BYTES):
43 size += len(chunk)
44 validate_file_size(size)
45 content += chunk
46
47 if response.status_code == HTTP_RESULT_OK and content:
48 # Set a dummy file name that will be replaced
49 # anyway, just keep the valid extension
50 filename = 'file.' + content_type.split('/')[1]
51 return SimpleUploadedFile(filename, content, content_type)
52
53
54 class YouTubeDownloader(Downloader):
55 @staticmethod
56 def download(url: str):
57 yt = YouTube()
58 yt.from_url(url)
59 videos = yt.filter(YOUTUBE_VIDEO_FORMAT)
60 if len(videos) > 0:
61 video = videos[0]
62 return Downloader.download(video.url)
63
64 @staticmethod
65 def handles(url: str) -> bool:
66 return YOUTUBE_URL.match(url)
1 NO CONTENT: new file 100644, binary diff hidden
NO CONTENT: new file 100644, binary diff hidden
1 NO CONTENT: new file 100644, binary diff hidden
NO CONTENT: new file 100644, binary diff hidden
1 NO CONTENT: new file 100644, binary diff hidden
NO CONTENT: new file 100644, binary diff hidden
@@ -1,36 +1,37 b''
1 bc8fce57a613175450b8b6d933cdd85f22c04658 1.1
1 bc8fce57a613175450b8b6d933cdd85f22c04658 1.1
2 784258eb652c563c288ca7652c33f52cd4733d83 1.1-stable
2 784258eb652c563c288ca7652c33f52cd4733d83 1.1-stable
3 1b53a22467a8fccc798935d7a26efe78e4bc7b25 1.2-stable
3 1b53a22467a8fccc798935d7a26efe78e4bc7b25 1.2-stable
4 1713fb7543386089e364c39703b79e57d3d851f0 1.3
4 1713fb7543386089e364c39703b79e57d3d851f0 1.3
5 80f183ebbe132ea8433eacae9431360f31fe7083 1.4
5 80f183ebbe132ea8433eacae9431360f31fe7083 1.4
6 4330ff5a2bf6c543d8aaae8a43de1dc062f3bd13 1.4.1
6 4330ff5a2bf6c543d8aaae8a43de1dc062f3bd13 1.4.1
7 8531d7b001392289a6b761f38c73a257606552ad 1.5
7 8531d7b001392289a6b761f38c73a257606552ad 1.5
8 78e843c8b04b5a81cee5aa24601e305fae75da24 1.5.1
8 78e843c8b04b5a81cee5aa24601e305fae75da24 1.5.1
9 4f92838730ed9aa1d17651bbcdca19a097fd0c37 1.6
9 4f92838730ed9aa1d17651bbcdca19a097fd0c37 1.6
10 4bac2f37ea463337ddd27f98e7985407a74de504 1.7
10 4bac2f37ea463337ddd27f98e7985407a74de504 1.7
11 1c4febea92c6503ae557fba73b2768659ae90d24 1.7.1
11 1c4febea92c6503ae557fba73b2768659ae90d24 1.7.1
12 56a4a4578fc454ee455e33dd74a2cc82234bcb59 1.7.2
12 56a4a4578fc454ee455e33dd74a2cc82234bcb59 1.7.2
13 34d6f3d5deb22be56b6c1512ec654bd7f6e03bcc 1.7.3
13 34d6f3d5deb22be56b6c1512ec654bd7f6e03bcc 1.7.3
14 f5cca33d29c673b67d43f310bebc4e3a21c6d04c 1.7.4
14 f5cca33d29c673b67d43f310bebc4e3a21c6d04c 1.7.4
15 7f7c33ba6e3f3797ca866c5ed5d358a2393f1371 1.8
15 7f7c33ba6e3f3797ca866c5ed5d358a2393f1371 1.8
16 a6b9dd9547bdc17b681502efcceb17aa5c09adf4 1.8.1
16 a6b9dd9547bdc17b681502efcceb17aa5c09adf4 1.8.1
17 8318fa1615d1946e4519f5735ae880909521990d 2.0
17 8318fa1615d1946e4519f5735ae880909521990d 2.0
18 e23590ee7e2067a3f0fc3cbcfd66404b47127feb 2.1
18 e23590ee7e2067a3f0fc3cbcfd66404b47127feb 2.1
19 4d998aba79e4abf0a2e78e93baaa2c2800b1c49c 2.2
19 4d998aba79e4abf0a2e78e93baaa2c2800b1c49c 2.2
20 07fdef4ac33a859250d03f17c594089792bca615 2.2.1
20 07fdef4ac33a859250d03f17c594089792bca615 2.2.1
21 bcc74d45f060ecd3ff06ff448165aea0d026cb3e 2.2.2
21 bcc74d45f060ecd3ff06ff448165aea0d026cb3e 2.2.2
22 b0e629ff24eb47a449ecfb455dc6cc600d18c9e2 2.2.3
22 b0e629ff24eb47a449ecfb455dc6cc600d18c9e2 2.2.3
23 1b52ba60f17fd7c90912c14d9d17e880b7952d01 2.2.4
23 1b52ba60f17fd7c90912c14d9d17e880b7952d01 2.2.4
24 957e2fec91468f739b0fc2b9936d564505048c68 2.3.0
24 957e2fec91468f739b0fc2b9936d564505048c68 2.3.0
25 bb91141c6ea5c822ccbe2d46c3c48bdab683b77d 2.4.0
25 bb91141c6ea5c822ccbe2d46c3c48bdab683b77d 2.4.0
26 97eb184637e5691b288eaf6b03e8971f3364c239 2.5.0
26 97eb184637e5691b288eaf6b03e8971f3364c239 2.5.0
27 119fafc5381b933bf30d97be0b278349f6135075 2.5.1
27 119fafc5381b933bf30d97be0b278349f6135075 2.5.1
28 d528d76d3242cced614fa11bb63f3d342e4e1d09 2.5.2
28 d528d76d3242cced614fa11bb63f3d342e4e1d09 2.5.2
29 1b631781ced34fbdeec032e7674bc4e131724699 2.6.0
29 1b631781ced34fbdeec032e7674bc4e131724699 2.6.0
30 0f2ef17dc0de678ada279bf7eedf6c5585f1fd7a 2.6.1
30 0f2ef17dc0de678ada279bf7eedf6c5585f1fd7a 2.6.1
31 d53fc814a424d7fd90f23025c87b87baa164450e 2.7.0
31 d53fc814a424d7fd90f23025c87b87baa164450e 2.7.0
32 836d8bb9fcd930b952b9a02029442c71c2441983 2.8.0
32 836d8bb9fcd930b952b9a02029442c71c2441983 2.8.0
33 dfb6c481b1a2c33705de9a9b5304bc924c46b202 2.8.1
33 dfb6c481b1a2c33705de9a9b5304bc924c46b202 2.8.1
34 4a5bec08ccfb47a27f9e98698f12dd5b7246623b 2.8.2
34 4a5bec08ccfb47a27f9e98698f12dd5b7246623b 2.8.2
35 604935b98f5b5e4a5e903594f048046e1fbb3519 2.8.3
35 604935b98f5b5e4a5e903594f048046e1fbb3519 2.8.3
36 c48ffdc671566069ed0f33644da1229277f3cd18 2.9.0
36 c48ffdc671566069ed0f33644da1229277f3cd18 2.9.0
37 d66dc192d4e089ba85325afeef5229b73cb0fde4 2.10.0
@@ -1,148 +1,172 b''
1 from boards.models import Tag
1 from boards.models import Tag
2 from boards.models.thread import FAV_THREAD_NO_UPDATES
2
3
3 MAX_TRIPCODE_COLLISIONS = 50
4 MAX_TRIPCODE_COLLISIONS = 50
4
5
5 __author__ = 'neko259'
6 __author__ = 'neko259'
6
7
7 SESSION_SETTING = 'setting'
8 SESSION_SETTING = 'setting'
8
9
9 # Remove this, it is not used any more cause there is a user's permission
10 # Remove this, it is not used any more cause there is a user's permission
10 PERMISSION_MODERATE = 'moderator'
11 PERMISSION_MODERATE = 'moderator'
11
12
12 SETTING_THEME = 'theme'
13 SETTING_THEME = 'theme'
13 SETTING_FAVORITE_TAGS = 'favorite_tags'
14 SETTING_FAVORITE_TAGS = 'favorite_tags'
15 SETTING_FAVORITE_THREADS = 'favorite_threads'
14 SETTING_HIDDEN_TAGS = 'hidden_tags'
16 SETTING_HIDDEN_TAGS = 'hidden_tags'
15 SETTING_PERMISSIONS = 'permissions'
17 SETTING_PERMISSIONS = 'permissions'
16 SETTING_USERNAME = 'username'
18 SETTING_USERNAME = 'username'
17 SETTING_LAST_NOTIFICATION_ID = 'last_notification'
19 SETTING_LAST_NOTIFICATION_ID = 'last_notification'
18 SETTING_IMAGE_VIEWER = 'image_viewer'
20 SETTING_IMAGE_VIEWER = 'image_viewer'
19 SETTING_TRIPCODE = 'tripcode'
21 SETTING_TRIPCODE = 'tripcode'
20
22
21 DEFAULT_THEME = 'md'
23 DEFAULT_THEME = 'md'
22
24
23
25
24 class SettingsManager:
26 class SettingsManager:
25 """
27 """
26 Base settings manager class. get_setting and set_setting methods should
28 Base settings manager class. get_setting and set_setting methods should
27 be overriden.
29 be overriden.
28 """
30 """
29 def __init__(self):
31 def __init__(self):
30 pass
32 pass
31
33
32 def get_theme(self) -> str:
34 def get_theme(self) -> str:
33 theme = self.get_setting(SETTING_THEME)
35 theme = self.get_setting(SETTING_THEME)
34 if not theme:
36 if not theme:
35 theme = DEFAULT_THEME
37 theme = DEFAULT_THEME
36 self.set_setting(SETTING_THEME, theme)
38 self.set_setting(SETTING_THEME, theme)
37
39
38 return theme
40 return theme
39
41
40 def set_theme(self, theme):
42 def set_theme(self, theme):
41 self.set_setting(SETTING_THEME, theme)
43 self.set_setting(SETTING_THEME, theme)
42
44
43 def has_permission(self, permission):
45 def has_permission(self, permission):
44 permissions = self.get_setting(SETTING_PERMISSIONS)
46 permissions = self.get_setting(SETTING_PERMISSIONS)
45 if permissions:
47 if permissions:
46 return permission in permissions
48 return permission in permissions
47 else:
49 else:
48 return False
50 return False
49
51
50 def get_setting(self, setting, default=None):
52 def get_setting(self, setting, default=None):
51 pass
53 pass
52
54
53 def set_setting(self, setting, value):
55 def set_setting(self, setting, value):
54 pass
56 pass
55
57
56 def add_permission(self, permission):
58 def add_permission(self, permission):
57 permissions = self.get_setting(SETTING_PERMISSIONS)
59 permissions = self.get_setting(SETTING_PERMISSIONS)
58 if not permissions:
60 if not permissions:
59 permissions = [permission]
61 permissions = [permission]
60 else:
62 else:
61 permissions.append(permission)
63 permissions.append(permission)
62 self.set_setting(SETTING_PERMISSIONS, permissions)
64 self.set_setting(SETTING_PERMISSIONS, permissions)
63
65
64 def del_permission(self, permission):
66 def del_permission(self, permission):
65 permissions = self.get_setting(SETTING_PERMISSIONS)
67 permissions = self.get_setting(SETTING_PERMISSIONS)
66 if not permissions:
68 if not permissions:
67 permissions = []
69 permissions = []
68 else:
70 else:
69 permissions.remove(permission)
71 permissions.remove(permission)
70 self.set_setting(SETTING_PERMISSIONS, permissions)
72 self.set_setting(SETTING_PERMISSIONS, permissions)
71
73
72 def get_fav_tags(self) -> list:
74 def get_fav_tags(self) -> list:
73 tag_names = self.get_setting(SETTING_FAVORITE_TAGS)
75 tag_names = self.get_setting(SETTING_FAVORITE_TAGS)
74 tags = []
76 tags = []
75 if tag_names:
77 if tag_names:
76 tags = list(Tag.objects.filter(name__in=tag_names))
78 tags = list(Tag.objects.filter(name__in=tag_names))
77 return tags
79 return tags
78
80
79 def add_fav_tag(self, tag):
81 def add_fav_tag(self, tag):
80 tags = self.get_setting(SETTING_FAVORITE_TAGS)
82 tags = self.get_setting(SETTING_FAVORITE_TAGS)
81 if not tags:
83 if not tags:
82 tags = [tag.name]
84 tags = [tag.name]
83 else:
85 else:
84 if not tag.name in tags:
86 if not tag.name in tags:
85 tags.append(tag.name)
87 tags.append(tag.name)
86
88
87 tags.sort()
89 tags.sort()
88 self.set_setting(SETTING_FAVORITE_TAGS, tags)
90 self.set_setting(SETTING_FAVORITE_TAGS, tags)
89
91
90 def del_fav_tag(self, tag):
92 def del_fav_tag(self, tag):
91 tags = self.get_setting(SETTING_FAVORITE_TAGS)
93 tags = self.get_setting(SETTING_FAVORITE_TAGS)
92 if tag.name in tags:
94 if tag.name in tags:
93 tags.remove(tag.name)
95 tags.remove(tag.name)
94 self.set_setting(SETTING_FAVORITE_TAGS, tags)
96 self.set_setting(SETTING_FAVORITE_TAGS, tags)
95
97
96 def get_hidden_tags(self) -> list:
98 def get_hidden_tags(self) -> list:
97 tag_names = self.get_setting(SETTING_HIDDEN_TAGS)
99 tag_names = self.get_setting(SETTING_HIDDEN_TAGS)
98 tags = []
100 tags = []
99 if tag_names:
101 if tag_names:
100 tags = list(Tag.objects.filter(name__in=tag_names))
102 tags = list(Tag.objects.filter(name__in=tag_names))
101
103
102 return tags
104 return tags
103
105
104 def add_hidden_tag(self, tag):
106 def add_hidden_tag(self, tag):
105 tags = self.get_setting(SETTING_HIDDEN_TAGS)
107 tags = self.get_setting(SETTING_HIDDEN_TAGS)
106 if not tags:
108 if not tags:
107 tags = [tag.name]
109 tags = [tag.name]
108 else:
110 else:
109 if not tag.name in tags:
111 if not tag.name in tags:
110 tags.append(tag.name)
112 tags.append(tag.name)
111
113
112 tags.sort()
114 tags.sort()
113 self.set_setting(SETTING_HIDDEN_TAGS, tags)
115 self.set_setting(SETTING_HIDDEN_TAGS, tags)
114
116
115 def del_hidden_tag(self, tag):
117 def del_hidden_tag(self, tag):
116 tags = self.get_setting(SETTING_HIDDEN_TAGS)
118 tags = self.get_setting(SETTING_HIDDEN_TAGS)
117 if tag.name in tags:
119 if tag.name in tags:
118 tags.remove(tag.name)
120 tags.remove(tag.name)
119 self.set_setting(SETTING_HIDDEN_TAGS, tags)
121 self.set_setting(SETTING_HIDDEN_TAGS, tags)
120
122
123 def get_fav_threads(self) -> dict:
124 return self.get_setting(SETTING_FAVORITE_THREADS, default=dict())
125
126 def add_or_read_fav_thread(self, opening_post):
127 threads = self.get_fav_threads()
128 thread = opening_post.get_thread()
129 # Don't check for new posts if the thread is archived already
130 if thread.is_archived():
131 last_id = FAV_THREAD_NO_UPDATES
132 else:
133 last_id = thread.get_replies().last().id
134 threads[str(opening_post.id)] = last_id
135 self.set_setting(SETTING_FAVORITE_THREADS, threads)
136
137 def del_fav_thread(self, opening_post):
138 threads = self.get_fav_threads()
139 if self.thread_is_fav(opening_post):
140 del threads[str(opening_post.id)]
141 self.set_setting(SETTING_FAVORITE_THREADS, threads)
142
143 def thread_is_fav(self, opening_post):
144 return str(opening_post.id) in self.get_fav_threads()
121
145
122 class SessionSettingsManager(SettingsManager):
146 class SessionSettingsManager(SettingsManager):
123 """
147 """
124 Session-based settings manager. All settings are saved to the user's
148 Session-based settings manager. All settings are saved to the user's
125 session.
149 session.
126 """
150 """
127 def __init__(self, session):
151 def __init__(self, session):
128 SettingsManager.__init__(self)
152 SettingsManager.__init__(self)
129 self.session = session
153 self.session = session
130
154
131 def get_setting(self, setting, default=None):
155 def get_setting(self, setting, default=None):
132 if setting in self.session:
156 if setting in self.session:
133 return self.session[setting]
157 return self.session[setting]
134 else:
158 else:
135 self.set_setting(setting, default)
159 self.set_setting(setting, default)
136 return default
160 return default
137
161
138 def set_setting(self, setting, value):
162 def set_setting(self, setting, value):
139 self.session[setting] = value
163 self.session[setting] = value
140
164
141
165
142 def get_settings_manager(request) -> SettingsManager:
166 def get_settings_manager(request) -> SettingsManager:
143 """
167 """
144 Get settings manager based on the request object. Currently only
168 Get settings manager based on the request object. Currently only
145 session-based manager is supported. In the future, cookie-based or
169 session-based manager is supported. In the future, cookie-based or
146 database-based managers could be implemented.
170 database-based managers could be implemented.
147 """
171 """
148 return SessionSettingsManager(request.session)
172 return SessionSettingsManager(request.session)
@@ -1,71 +1,81 b''
1 from django.contrib import admin
1 from django.contrib import admin
2 from boards.models import Post, Tag, Ban, Thread, KeyPair, Banner
2 from boards.models import Post, Tag, Ban, Thread, KeyPair, Banner
3 from django.utils.translation import ugettext_lazy as _
3 from django.utils.translation import ugettext_lazy as _
4
4
5
5
6 @admin.register(Post)
6 @admin.register(Post)
7 class PostAdmin(admin.ModelAdmin):
7 class PostAdmin(admin.ModelAdmin):
8
8
9 list_display = ('id', 'title', 'text', 'poster_ip')
9 list_display = ('id', 'title', 'text', 'poster_ip')
10 list_filter = ('pub_time',)
10 list_filter = ('pub_time',)
11 search_fields = ('id', 'title', 'text', 'poster_ip')
11 search_fields = ('id', 'title', 'text', 'poster_ip')
12 exclude = ('referenced_posts', 'refmap')
12 exclude = ('referenced_posts', 'refmap')
13 readonly_fields = ('poster_ip', 'threads', 'thread', 'images', 'uid')
13 readonly_fields = ('poster_ip', 'threads', 'thread', 'images', 'uid')
14
14
15 def ban_poster(self, request, queryset):
15 def ban_poster(self, request, queryset):
16 bans = 0
16 bans = 0
17 for post in queryset:
17 for post in queryset:
18 poster_ip = post.poster_ip
18 poster_ip = post.poster_ip
19 ban, created = Ban.objects.get_or_create(ip=poster_ip)
19 ban, created = Ban.objects.get_or_create(ip=poster_ip)
20 if created:
20 if created:
21 bans += 1
21 bans += 1
22 self.message_user(request, _('{} posters were banned').format(bans))
22 self.message_user(request, _('{} posters were banned').format(bans))
23
23
24 actions = ['ban_poster']
24 actions = ['ban_poster']
25
25
26
26
27 @admin.register(Tag)
27 @admin.register(Tag)
28 class TagAdmin(admin.ModelAdmin):
28 class TagAdmin(admin.ModelAdmin):
29
29
30 def thread_count(self, obj: Tag) -> int:
30 def thread_count(self, obj: Tag) -> int:
31 return obj.get_thread_count()
31 return obj.get_thread_count()
32
32
33 list_display = ('name', 'thread_count')
33 def display_children(self, obj: Tag):
34 return ', '.join([str(child) for child in obj.get_children().all()])
35
36 list_display = ('name', 'thread_count', 'display_children')
34 search_fields = ('name',)
37 search_fields = ('name',)
35
38
36
39
37 @admin.register(Thread)
40 @admin.register(Thread)
38 class ThreadAdmin(admin.ModelAdmin):
41 class ThreadAdmin(admin.ModelAdmin):
39
42
40 def title(self, obj: Thread) -> str:
43 def title(self, obj: Thread) -> str:
41 return obj.get_opening_post().get_title()
44 return obj.get_opening_post().get_title()
42
45
43 def reply_count(self, obj: Thread) -> int:
46 def reply_count(self, obj: Thread) -> int:
44 return obj.get_reply_count()
47 return obj.get_reply_count()
45
48
46 def ip(self, obj: Thread):
49 def ip(self, obj: Thread):
47 return obj.get_opening_post().poster_ip
50 return obj.get_opening_post().poster_ip
48
51
49 list_display = ('id', 'title', 'reply_count', 'archived', 'ip')
52 def display_tags(self, obj: Thread):
53 return ', '.join([str(tag) for tag in obj.get_tags().all()])
54
55 def op(self, obj: Thread):
56 return obj.get_opening_post_id()
57
58 list_display = ('id', 'op', 'title', 'reply_count', 'archived', 'ip',
59 'display_tags')
50 list_filter = ('bump_time', 'archived', 'bumpable')
60 list_filter = ('bump_time', 'archived', 'bumpable')
51 search_fields = ('id', 'title')
61 search_fields = ('id', 'title')
52 filter_horizontal = ('tags',)
62 filter_horizontal = ('tags',)
53
63
54
64
55 @admin.register(KeyPair)
65 @admin.register(KeyPair)
56 class KeyPairAdmin(admin.ModelAdmin):
66 class KeyPairAdmin(admin.ModelAdmin):
57 list_display = ('public_key', 'primary')
67 list_display = ('public_key', 'primary')
58 list_filter = ('primary',)
68 list_filter = ('primary',)
59 search_fields = ('public_key',)
69 search_fields = ('public_key',)
60
70
61
71
62 @admin.register(Ban)
72 @admin.register(Ban)
63 class BanAdmin(admin.ModelAdmin):
73 class BanAdmin(admin.ModelAdmin):
64 list_display = ('ip', 'can_read')
74 list_display = ('ip', 'can_read')
65 list_filter = ('can_read',)
75 list_filter = ('can_read',)
66 search_fields = ('ip',)
76 search_fields = ('ip',)
67
77
68
78
69 @admin.register(Banner)
79 @admin.register(Banner)
70 class BannerAdmin(admin.ModelAdmin):
80 class BannerAdmin(admin.ModelAdmin):
71 list_display = ('title', 'text')
81 list_display = ('title', 'text')
@@ -1,33 +1,33 b''
1 [Version]
1 [Version]
2 Version = 2.9.0 Claire
2 Version = 2.10.0 BT
3 SiteName = Neboard DEV
3 SiteName = Neboard DEV
4
4
5 [Cache]
5 [Cache]
6 # Timeout for caching, if cache is used
6 # Timeout for caching, if cache is used
7 CacheTimeout = 600
7 CacheTimeout = 600
8
8
9 [Forms]
9 [Forms]
10 # Max post length in characters
10 # Max post length in characters
11 MaxTextLength = 30000
11 MaxTextLength = 30000
12 MaxFileSize = 8000000
12 MaxFileSize = 8000000
13 LimitPostingSpeed = false
13 LimitPostingSpeed = false
14
14
15 [Messages]
15 [Messages]
16 # Thread bumplimit
16 # Thread bumplimit
17 MaxPostsPerThread = 10
17 MaxPostsPerThread = 10
18 # Old posts will be archived or deleted if this value is reached
18 # Old posts will be archived or deleted if this value is reached
19 MaxThreadCount = 5
19 MaxThreadCount = 5
20
20
21 [View]
21 [View]
22 DefaultTheme = md
22 DefaultTheme = md
23 DefaultImageViewer = simple
23 DefaultImageViewer = simple
24 LastRepliesCount = 3
24 LastRepliesCount = 3
25 ThreadsPerPage = 3
25 ThreadsPerPage = 3
26
26
27 [Storage]
27 [Storage]
28 # Enable archiving threads instead of deletion when the thread limit is reached
28 # Enable archiving threads instead of deletion when the thread limit is reached
29 ArchiveThreads = true
29 ArchiveThreads = true
30
30
31 [External]
31 [External]
32 # Thread update
32 # Thread update
33 WebsocketsEnabled = false
33 WebsocketsEnabled = false
@@ -1,63 +1,68 b''
1 from boards.abstracts.settingsmanager import get_settings_manager, \
1 from boards.abstracts.settingsmanager import get_settings_manager, \
2 SETTING_USERNAME, SETTING_LAST_NOTIFICATION_ID, SETTING_IMAGE_VIEWER
2 SETTING_USERNAME, SETTING_LAST_NOTIFICATION_ID, SETTING_IMAGE_VIEWER
3 from boards.models.user import Notification
3 from boards.models.user import Notification
4
4
5 __author__ = 'neko259'
5 __author__ = 'neko259'
6
6
7 from boards import settings, utils
7 from boards import settings, utils
8 from boards.models import Post, Tag
8 from boards.models import Post, Tag
9
9
10 CONTEXT_SITE_NAME = 'site_name'
10 CONTEXT_SITE_NAME = 'site_name'
11 CONTEXT_VERSION = 'version'
11 CONTEXT_VERSION = 'version'
12 CONTEXT_MODERATOR = 'moderator'
12 CONTEXT_MODERATOR = 'moderator'
13 CONTEXT_THEME_CSS = 'theme_css'
13 CONTEXT_THEME_CSS = 'theme_css'
14 CONTEXT_THEME = 'theme'
14 CONTEXT_THEME = 'theme'
15 CONTEXT_PPD = 'posts_per_day'
15 CONTEXT_PPD = 'posts_per_day'
16 CONTEXT_TAGS = 'tags'
16 CONTEXT_TAGS = 'tags'
17 CONTEXT_USER = 'user'
17 CONTEXT_USER = 'user'
18 CONTEXT_NEW_NOTIFICATIONS_COUNT = 'new_notifications_count'
18 CONTEXT_NEW_NOTIFICATIONS_COUNT = 'new_notifications_count'
19 CONTEXT_USERNAME = 'username'
19 CONTEXT_USERNAME = 'username'
20 CONTEXT_TAGS_STR = 'tags_str'
20 CONTEXT_TAGS_STR = 'tags_str'
21 CONTEXT_IMAGE_VIEWER = 'image_viewer'
21 CONTEXT_IMAGE_VIEWER = 'image_viewer'
22 CONTEXT_HAS_FAV_THREADS = 'has_fav_threads'
22
23
23
24
24 def get_notifications(context, request):
25 def get_notifications(context, request):
25 settings_manager = get_settings_manager(request)
26 settings_manager = get_settings_manager(request)
26 username = settings_manager.get_setting(SETTING_USERNAME)
27 username = settings_manager.get_setting(SETTING_USERNAME)
27 new_notifications_count = 0
28 new_notifications_count = 0
28 if username is not None and len(username) > 0:
29 if username is not None and len(username) > 0:
29 last_notification_id = settings_manager.get_setting(
30 last_notification_id = settings_manager.get_setting(
30 SETTING_LAST_NOTIFICATION_ID)
31 SETTING_LAST_NOTIFICATION_ID)
31
32
32 new_notifications_count = Notification.objects.get_notification_posts(
33 new_notifications_count = Notification.objects.get_notification_posts(
33 username=username, last=last_notification_id).count()
34 username=username, last=last_notification_id).count()
34 context[CONTEXT_NEW_NOTIFICATIONS_COUNT] = new_notifications_count
35 context[CONTEXT_NEW_NOTIFICATIONS_COUNT] = new_notifications_count
35 context[CONTEXT_USERNAME] = username
36 context[CONTEXT_USERNAME] = username
36
37
37
38
38 def user_and_ui_processor(request):
39 def user_and_ui_processor(request):
39 context = dict()
40 context = dict()
40
41
41 context[CONTEXT_PPD] = float(Post.objects.get_posts_per_day())
42 context[CONTEXT_PPD] = float(Post.objects.get_posts_per_day())
42
43
43 settings_manager = get_settings_manager(request)
44 settings_manager = get_settings_manager(request)
44 fav_tags = settings_manager.get_fav_tags()
45 fav_tags = settings_manager.get_fav_tags()
45 context[CONTEXT_TAGS] = fav_tags
46 context[CONTEXT_TAGS] = fav_tags
47
46 context[CONTEXT_TAGS_STR] = Tag.objects.get_tag_url_list(fav_tags)
48 context[CONTEXT_TAGS_STR] = Tag.objects.get_tag_url_list(fav_tags)
47 theme = settings_manager.get_theme()
49 theme = settings_manager.get_theme()
48 context[CONTEXT_THEME] = theme
50 context[CONTEXT_THEME] = theme
49 context[CONTEXT_THEME_CSS] = 'css/' + theme + '/base_page.css'
51 context[CONTEXT_THEME_CSS] = 'css/' + theme + '/base_page.css'
50
52
51 # This shows the moderator panel
53 # This shows the moderator panel
52 context[CONTEXT_MODERATOR] = utils.is_moderator(request)
54 context[CONTEXT_MODERATOR] = utils.is_moderator(request)
53
55
54 context[CONTEXT_VERSION] = settings.get('Version', 'Version')
56 context[CONTEXT_VERSION] = settings.get('Version', 'Version')
55 context[CONTEXT_SITE_NAME] = settings.get('Version', 'SiteName')
57 context[CONTEXT_SITE_NAME] = settings.get('Version', 'SiteName')
56
58
57 context[CONTEXT_IMAGE_VIEWER] = settings_manager.get_setting(
59 context[CONTEXT_IMAGE_VIEWER] = settings_manager.get_setting(
58 SETTING_IMAGE_VIEWER,
60 SETTING_IMAGE_VIEWER,
59 default=settings.get('View', 'DefaultImageViewer'))
61 default=settings.get('View', 'DefaultImageViewer'))
60
62
63 context[CONTEXT_HAS_FAV_THREADS] =\
64 len(settings_manager.get_fav_threads()) > 0
65
61 get_notifications(context, request)
66 get_notifications(context, request)
62
67
63 return context
68 return context
@@ -1,395 +1,372 b''
1 import hashlib
1 import hashlib
2 import re
2 import re
3 import time
3 import time
4
4
5 import pytz
5 import pytz
6 from django import forms
6 from django import forms
7 from django.core.files.uploadedfile import SimpleUploadedFile
7 from django.core.files.uploadedfile import SimpleUploadedFile
8 from django.core.exceptions import ObjectDoesNotExist
8 from django.core.exceptions import ObjectDoesNotExist
9 from django.forms.util import ErrorList
9 from django.forms.util import ErrorList
10 from django.utils.translation import ugettext_lazy as _
10 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
11 import requests
12
11
13 from boards.mdx_neboard import formatters
12 from boards.mdx_neboard import formatters
13 from boards.models.attachment.downloaders import Downloader
14 from boards.models.post import TITLE_MAX_LENGTH
14 from boards.models.post import TITLE_MAX_LENGTH
15 from boards.models import Tag, Post
15 from boards.models import Tag, Post
16 from boards.utils import validate_file_size
16 from neboard import settings
17 from neboard import settings
17 import boards.settings as board_settings
18 import boards.settings as board_settings
18 import neboard
19 import neboard
19
20
20 HEADER_CONTENT_LENGTH = 'content-length'
21 HEADER_CONTENT_TYPE = 'content-type'
22
23 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
21 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
24
22
25 VETERAN_POSTING_DELAY = 5
23 VETERAN_POSTING_DELAY = 5
26
24
27 ATTRIBUTE_PLACEHOLDER = 'placeholder'
25 ATTRIBUTE_PLACEHOLDER = 'placeholder'
28 ATTRIBUTE_ROWS = 'rows'
26 ATTRIBUTE_ROWS = 'rows'
29
27
30 LAST_POST_TIME = 'last_post_time'
28 LAST_POST_TIME = 'last_post_time'
31 LAST_LOGIN_TIME = 'last_login_time'
29 LAST_LOGIN_TIME = 'last_login_time'
32 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
30 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
33 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
31 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
34
32
35 LABEL_TITLE = _('Title')
33 LABEL_TITLE = _('Title')
36 LABEL_TEXT = _('Text')
34 LABEL_TEXT = _('Text')
37 LABEL_TAG = _('Tag')
35 LABEL_TAG = _('Tag')
38 LABEL_SEARCH = _('Search')
36 LABEL_SEARCH = _('Search')
39
37
40 ERROR_SPEED = _('Please wait %s seconds before sending message')
38 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
39 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
41
40
42 TAG_MAX_LENGTH = 20
41 TAG_MAX_LENGTH = 20
43
42
44 FILE_DOWNLOAD_CHUNK_BYTES = 100000
45
46 HTTP_RESULT_OK = 200
47
48 TEXTAREA_ROWS = 4
43 TEXTAREA_ROWS = 4
49
44
50
45
51 def get_timezones():
46 def get_timezones():
52 timezones = []
47 timezones = []
53 for tz in pytz.common_timezones:
48 for tz in pytz.common_timezones:
54 timezones.append((tz, tz),)
49 timezones.append((tz, tz),)
55 return timezones
50 return timezones
56
51
57
52
58 class FormatPanel(forms.Textarea):
53 class FormatPanel(forms.Textarea):
59 """
54 """
60 Panel for text formatting. Consists of buttons to add different tags to the
55 Panel for text formatting. Consists of buttons to add different tags to the
61 form text area.
56 form text area.
62 """
57 """
63
58
64 def render(self, name, value, attrs=None):
59 def render(self, name, value, attrs=None):
65 output = '<div id="mark-panel">'
60 output = '<div id="mark-panel">'
66 for formatter in formatters:
61 for formatter in formatters:
67 output += '<span class="mark_btn"' + \
62 output += '<span class="mark_btn"' + \
68 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
63 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
69 '\', \'' + formatter.format_right + '\')">' + \
64 '\', \'' + formatter.format_right + '\')">' + \
70 formatter.preview_left + formatter.name + \
65 formatter.preview_left + formatter.name + \
71 formatter.preview_right + '</span>'
66 formatter.preview_right + '</span>'
72
67
73 output += '</div>'
68 output += '</div>'
74 output += super(FormatPanel, self).render(name, value, attrs=None)
69 output += super(FormatPanel, self).render(name, value, attrs=None)
75
70
76 return output
71 return output
77
72
78
73
79 class PlainErrorList(ErrorList):
74 class PlainErrorList(ErrorList):
80 def __unicode__(self):
75 def __unicode__(self):
81 return self.as_text()
76 return self.as_text()
82
77
83 def as_text(self):
78 def as_text(self):
84 return ''.join(['(!) %s ' % e for e in self])
79 return ''.join(['(!) %s ' % e for e in self])
85
80
86
81
87 class NeboardForm(forms.Form):
82 class NeboardForm(forms.Form):
88 """
83 """
89 Form with neboard-specific formatting.
84 Form with neboard-specific formatting.
90 """
85 """
91
86
92 def as_div(self):
87 def as_div(self):
93 """
88 """
94 Returns this form rendered as HTML <as_div>s.
89 Returns this form rendered as HTML <as_div>s.
95 """
90 """
96
91
97 return self._html_output(
92 return self._html_output(
98 # TODO Do not show hidden rows in the list here
93 # TODO Do not show hidden rows in the list here
99 normal_row='<div class="form-row">'
94 normal_row='<div class="form-row">'
100 '<div class="form-label">'
95 '<div class="form-label">'
101 '%(label)s'
96 '%(label)s'
102 '</div>'
97 '</div>'
103 '<div class="form-input">'
98 '<div class="form-input">'
104 '%(field)s'
99 '%(field)s'
105 '</div>'
100 '</div>'
106 '</div>'
101 '</div>'
107 '<div class="form-row">'
102 '<div class="form-row">'
108 '%(help_text)s'
103 '%(help_text)s'
109 '</div>',
104 '</div>',
110 error_row='<div class="form-row">'
105 error_row='<div class="form-row">'
111 '<div class="form-label"></div>'
106 '<div class="form-label"></div>'
112 '<div class="form-errors">%s</div>'
107 '<div class="form-errors">%s</div>'
113 '</div>',
108 '</div>',
114 row_ender='</div>',
109 row_ender='</div>',
115 help_text_html='%s',
110 help_text_html='%s',
116 errors_on_separate_row=True)
111 errors_on_separate_row=True)
117
112
118 def as_json_errors(self):
113 def as_json_errors(self):
119 errors = []
114 errors = []
120
115
121 for name, field in list(self.fields.items()):
116 for name, field in list(self.fields.items()):
122 if self[name].errors:
117 if self[name].errors:
123 errors.append({
118 errors.append({
124 'field': name,
119 'field': name,
125 'errors': self[name].errors.as_text(),
120 'errors': self[name].errors.as_text(),
126 })
121 })
127
122
128 return errors
123 return errors
129
124
130
125
131 class PostForm(NeboardForm):
126 class PostForm(NeboardForm):
132
127
133 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
128 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
134 label=LABEL_TITLE,
129 label=LABEL_TITLE,
135 widget=forms.TextInput(
130 widget=forms.TextInput(
136 attrs={ATTRIBUTE_PLACEHOLDER:
131 attrs={ATTRIBUTE_PLACEHOLDER:
137 'test#tripcode'}))
132 'test#tripcode'}))
138 text = forms.CharField(
133 text = forms.CharField(
139 widget=FormatPanel(attrs={
134 widget=FormatPanel(attrs={
140 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
135 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
141 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
136 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
142 }),
137 }),
143 required=False, label=LABEL_TEXT)
138 required=False, label=LABEL_TEXT)
144 file = forms.FileField(required=False, label=_('File'),
139 file = forms.FileField(required=False, label=_('File'),
145 widget=forms.ClearableFileInput(
140 widget=forms.ClearableFileInput(
146 attrs={'accept': 'file/*'}))
141 attrs={'accept': 'file/*'}))
147 file_url = forms.CharField(required=False, label=_('File URL'),
142 file_url = forms.CharField(required=False, label=_('File URL'),
148 widget=forms.TextInput(
143 widget=forms.TextInput(
149 attrs={ATTRIBUTE_PLACEHOLDER:
144 attrs={ATTRIBUTE_PLACEHOLDER:
150 'http://example.com/image.png'}))
145 'http://example.com/image.png'}))
151
146
152 # This field is for spam prevention only
147 # This field is for spam prevention only
153 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
148 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
154 widget=forms.TextInput(attrs={
149 widget=forms.TextInput(attrs={
155 'class': 'form-email'}))
150 'class': 'form-email'}))
156 threads = forms.CharField(required=False, label=_('Additional threads'),
151 threads = forms.CharField(required=False, label=_('Additional threads'),
157 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
152 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
158 '123 456 789'}))
153 '123 456 789'}))
159
154
160 session = None
155 session = None
161 need_to_ban = False
156 need_to_ban = False
162
157
163 def clean_title(self):
158 def clean_title(self):
164 title = self.cleaned_data['title']
159 title = self.cleaned_data['title']
165 if title:
160 if title:
166 if len(title) > TITLE_MAX_LENGTH:
161 if len(title) > TITLE_MAX_LENGTH:
167 raise forms.ValidationError(_('Title must have less than %s '
162 raise forms.ValidationError(_('Title must have less than %s '
168 'characters') %
163 'characters') %
169 str(TITLE_MAX_LENGTH))
164 str(TITLE_MAX_LENGTH))
170 return title
165 return title
171
166
172 def clean_text(self):
167 def clean_text(self):
173 text = self.cleaned_data['text'].strip()
168 text = self.cleaned_data['text'].strip()
174 if text:
169 if text:
175 max_length = board_settings.get_int('Forms', 'MaxTextLength')
170 max_length = board_settings.get_int('Forms', 'MaxTextLength')
176 if len(text) > max_length:
171 if len(text) > max_length:
177 raise forms.ValidationError(_('Text must have less than %s '
172 raise forms.ValidationError(_('Text must have less than %s '
178 'characters') % str(max_length))
173 'characters') % str(max_length))
179 return text
174 return text
180
175
181 def clean_file(self):
176 def clean_file(self):
182 file = self.cleaned_data['file']
177 file = self.cleaned_data['file']
183
178
184 if file:
179 if file:
185 self.validate_file_size(file.size)
180 validate_file_size(file.size)
186
181
187 return file
182 return file
188
183
189 def clean_file_url(self):
184 def clean_file_url(self):
190 url = self.cleaned_data['file_url']
185 url = self.cleaned_data['file_url']
191
186
192 file = None
187 file = None
193 if url:
188 if url:
194 file = self._get_file_from_url(url)
189 file = self._get_file_from_url(url)
195
190
196 if not file:
191 if not file:
197 raise forms.ValidationError(_('Invalid URL'))
192 raise forms.ValidationError(_('Invalid URL'))
198 else:
193 else:
199 self.validate_file_size(file.size)
194 validate_file_size(file.size)
200
195
201 return file
196 return file
202
197
203 def clean_threads(self):
198 def clean_threads(self):
204 threads_str = self.cleaned_data['threads']
199 threads_str = self.cleaned_data['threads']
205
200
206 if len(threads_str) > 0:
201 if len(threads_str) > 0:
207 threads_id_list = threads_str.split(' ')
202 threads_id_list = threads_str.split(' ')
208
203
209 threads = list()
204 threads = list()
210
205
211 for thread_id in threads_id_list:
206 for thread_id in threads_id_list:
212 try:
207 try:
213 thread = Post.objects.get(id=int(thread_id))
208 thread = Post.objects.get(id=int(thread_id))
214 if not thread.is_opening() or thread.get_thread().archived:
209 if not thread.is_opening() or thread.get_thread().archived:
215 raise ObjectDoesNotExist()
210 raise ObjectDoesNotExist()
216 threads.append(thread)
211 threads.append(thread)
217 except (ObjectDoesNotExist, ValueError):
212 except (ObjectDoesNotExist, ValueError):
218 raise forms.ValidationError(_('Invalid additional thread list'))
213 raise forms.ValidationError(_('Invalid additional thread list'))
219
214
220 return threads
215 return threads
221
216
222 def clean(self):
217 def clean(self):
223 cleaned_data = super(PostForm, self).clean()
218 cleaned_data = super(PostForm, self).clean()
224
219
225 if cleaned_data['email']:
220 if cleaned_data['email']:
226 self.need_to_ban = True
221 self.need_to_ban = True
227 raise forms.ValidationError('A human cannot enter a hidden field')
222 raise forms.ValidationError('A human cannot enter a hidden field')
228
223
229 if not self.errors:
224 if not self.errors:
230 self._clean_text_file()
225 self._clean_text_file()
231
226
232 if not self.errors and self.session:
227 if not self.errors and self.session:
233 self._validate_posting_speed()
228 self._validate_posting_speed()
234
229
235 return cleaned_data
230 return cleaned_data
236
231
237 def get_file(self):
232 def get_file(self):
238 """
233 """
239 Gets file from form or URL.
234 Gets file from form or URL.
240 """
235 """
241
236
242 file = self.cleaned_data['file']
237 file = self.cleaned_data['file']
243 return file or self.cleaned_data['file_url']
238 return file or self.cleaned_data['file_url']
244
239
245 def get_tripcode(self):
240 def get_tripcode(self):
246 title = self.cleaned_data['title']
241 title = self.cleaned_data['title']
247 if title is not None and '#' in title:
242 if title is not None and '#' in title:
248 code = title.split('#', maxsplit=1)[1] + neboard.settings.SECRET_KEY
243 code = title.split('#', maxsplit=1)[1] + neboard.settings.SECRET_KEY
249 return hashlib.md5(code.encode()).hexdigest()
244 return hashlib.md5(code.encode()).hexdigest()
250
245
251 def get_title(self):
246 def get_title(self):
252 title = self.cleaned_data['title']
247 title = self.cleaned_data['title']
253 if title is not None and '#' in title:
248 if title is not None and '#' in title:
254 return title.split('#', maxsplit=1)[0]
249 return title.split('#', maxsplit=1)[0]
255 else:
250 else:
256 return title
251 return title
257
252
258 def _clean_text_file(self):
253 def _clean_text_file(self):
259 text = self.cleaned_data.get('text')
254 text = self.cleaned_data.get('text')
260 file = self.get_file()
255 file = self.get_file()
261
256
262 if (not text) and (not file):
257 if (not text) and (not file):
263 error_message = _('Either text or file must be entered.')
258 error_message = _('Either text or file must be entered.')
264 self._errors['text'] = self.error_class([error_message])
259 self._errors['text'] = self.error_class([error_message])
265
260
266 def _validate_posting_speed(self):
261 def _validate_posting_speed(self):
267 can_post = True
262 can_post = True
268
263
269 posting_delay = settings.POSTING_DELAY
264 posting_delay = settings.POSTING_DELAY
270
265
271 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
266 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
272 now = time.time()
267 now = time.time()
273
268
274 current_delay = 0
269 current_delay = 0
275 need_delay = False
276
270
277 if not LAST_POST_TIME in self.session:
271 if LAST_POST_TIME not in self.session:
278 self.session[LAST_POST_TIME] = now
272 self.session[LAST_POST_TIME] = now
279
273
280 need_delay = True
274 need_delay = True
281 else:
275 else:
282 last_post_time = self.session.get(LAST_POST_TIME)
276 last_post_time = self.session.get(LAST_POST_TIME)
283 current_delay = int(now - last_post_time)
277 current_delay = int(now - last_post_time)
284
278
285 need_delay = current_delay < posting_delay
279 need_delay = current_delay < posting_delay
286
280
287 if need_delay:
281 if need_delay:
288 error_message = ERROR_SPEED % str(posting_delay
282 delay = posting_delay - current_delay
289 - current_delay)
283 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
284 delay) % {'delay': delay}
290 self._errors['text'] = self.error_class([error_message])
285 self._errors['text'] = self.error_class([error_message])
291
286
292 can_post = False
287 can_post = False
293
288
294 if can_post:
289 if can_post:
295 self.session[LAST_POST_TIME] = now
290 self.session[LAST_POST_TIME] = now
296
291
297 def validate_file_size(self, size: int):
298 max_size = board_settings.get_int('Forms', 'MaxFileSize')
299 if size > max_size:
300 raise forms.ValidationError(
301 _('File must be less than %s bytes')
302 % str(max_size))
303
304 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
292 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
305 """
293 """
306 Gets an file file from URL.
294 Gets an file file from URL.
307 """
295 """
308
296
309 img_temp = None
297 img_temp = None
310
298
311 try:
299 try:
312 # Verify content headers
300 for downloader in Downloader.__subclasses__():
313 response_head = requests.head(url, verify=False)
301 if downloader.handles(url):
314 content_type = response_head.headers[HEADER_CONTENT_TYPE].split(';')[0]
302 return downloader.download(url)
315 length_header = response_head.headers.get(HEADER_CONTENT_LENGTH)
303 # If nobody of the specific downloaders handles this, use generic
316 if length_header:
304 # one
317 length = int(length_header)
305 return Downloader.download(url)
318 self.validate_file_size(length)
306 except forms.ValidationError as e:
319 # Get the actual content into memory
307 raise e
320 response = requests.get(url, verify=False, stream=True)
321
322 # Download file, stop if the size exceeds limit
323 size = 0
324 content = b''
325 for chunk in response.iter_content(FILE_DOWNLOAD_CHUNK_BYTES):
326 size += len(chunk)
327 self.validate_file_size(size)
328 content += chunk
329
330 if response.status_code == HTTP_RESULT_OK and content:
331 # Set a dummy file name that will be replaced
332 # anyway, just keep the valid extension
333 filename = 'file.' + content_type.split('/')[1]
334 img_temp = SimpleUploadedFile(filename, content,
335 content_type)
336 except Exception as e:
308 except Exception as e:
337 # Just return no file
309 # Just return no file
338 pass
310 pass
339
311
340 return img_temp
341
342
312
343 class ThreadForm(PostForm):
313 class ThreadForm(PostForm):
344
314
345 tags = forms.CharField(
315 tags = forms.CharField(
346 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
316 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
347 max_length=100, label=_('Tags'), required=True)
317 max_length=100, label=_('Tags'), required=True)
348
318
349 def clean_tags(self):
319 def clean_tags(self):
350 tags = self.cleaned_data['tags'].strip()
320 tags = self.cleaned_data['tags'].strip()
351
321
352 if not tags or not REGEX_TAGS.match(tags):
322 if not tags or not REGEX_TAGS.match(tags):
353 raise forms.ValidationError(
323 raise forms.ValidationError(
354 _('Inappropriate characters in tags.'))
324 _('Inappropriate characters in tags.'))
355
325
356 required_tag_exists = False
326 required_tag_exists = False
357 for tag in tags.split():
327 tag_set = set()
358 try:
328 for tag_string in tags.split():
359 Tag.objects.get(name=tag.strip().lower(), required=True)
329 tag, created = Tag.objects.get_or_create(name=tag_string.strip().lower())
330 tag_set.add(tag)
331
332 # If this is a new tag, don't check for its parents because nobody
333 # added them yet
334 if not created:
335 tag_set |= tag.get_all_parents()
336
337 for tag in tag_set:
338 if tag.required:
360 required_tag_exists = True
339 required_tag_exists = True
361 break
340 break
362 except ObjectDoesNotExist:
363 pass
364
341
365 if not required_tag_exists:
342 if not required_tag_exists:
366 all_tags = Tag.objects.filter(required=True)
343 all_tags = Tag.objects.filter(required=True)
367 raise forms.ValidationError(
344 raise forms.ValidationError(
368 _('Need at least one section.'))
345 _('Need at least one section.'))
369
346
370 return tags
347 return tag_set
371
348
372 def clean(self):
349 def clean(self):
373 cleaned_data = super(ThreadForm, self).clean()
350 cleaned_data = super(ThreadForm, self).clean()
374
351
375 return cleaned_data
352 return cleaned_data
376
353
377
354
378 class SettingsForm(NeboardForm):
355 class SettingsForm(NeboardForm):
379
356
380 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
357 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
381 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
358 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
382 username = forms.CharField(label=_('User name'), required=False)
359 username = forms.CharField(label=_('User name'), required=False)
383 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
360 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
384
361
385 def clean_username(self):
362 def clean_username(self):
386 username = self.cleaned_data['username']
363 username = self.cleaned_data['username']
387
364
388 if username and not REGEX_TAGS.match(username):
365 if username and not REGEX_TAGS.match(username):
389 raise forms.ValidationError(_('Inappropriate characters.'))
366 raise forms.ValidationError(_('Inappropriate characters.'))
390
367
391 return username
368 return username
392
369
393
370
394 class SearchForm(NeboardForm):
371 class SearchForm(NeboardForm):
395 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
372 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
1 NO CONTENT: modified file, binary diff hidden
NO CONTENT: modified file, binary diff hidden
@@ -1,462 +1,492 b''
1 # SOME DESCRIPTIVE TITLE.
1 # SOME DESCRIPTIVE TITLE.
2 # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
2 # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 # This file is distributed under the same license as the PACKAGE package.
3 # This file is distributed under the same license as the PACKAGE package.
4 # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
4 # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
5 #
5 #
6 msgid ""
6 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-08-22 15:07+0300\n"
10 "POT-Creation-Date: 2015-09-12 12:48+0300\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"
14 "Language: ru\n"
14 "Language: ru\n"
15 "MIME-Version: 1.0\n"
15 "MIME-Version: 1.0\n"
16 "Content-Type: text/plain; charset=UTF-8\n"
16 "Content-Type: text/plain; charset=UTF-8\n"
17 "Content-Transfer-Encoding: 8bit\n"
17 "Content-Transfer-Encoding: 8bit\n"
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
21 #: admin.py:22
22 msgid "{} posters were banned"
22 msgid "{} posters were banned"
23 msgstr ""
23 msgstr ""
24
24
25 #: authors.py:9
25 #: authors.py:9
26 msgid "author"
26 msgid "author"
27 msgstr "Π°Π²Ρ‚ΠΎΡ€"
27 msgstr "Π°Π²Ρ‚ΠΎΡ€"
28
28
29 #: authors.py:10
29 #: authors.py:10
30 msgid "developer"
30 msgid "developer"
31 msgstr "Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ"
31 msgstr "Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ"
32
32
33 #: authors.py:11
33 #: authors.py:11
34 msgid "javascript developer"
34 msgid "javascript developer"
35 msgstr "Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ javascript"
35 msgstr "Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ javascript"
36
36
37 #: authors.py:12
37 #: authors.py:12
38 msgid "designer"
38 msgid "designer"
39 msgstr "Π΄ΠΈΠ·Π°ΠΉΠ½Π΅Ρ€"
39 msgstr "Π΄ΠΈΠ·Π°ΠΉΠ½Π΅Ρ€"
40
40
41 #: forms.py:31
41 #: forms.py:30
42 msgid "Type message here. Use formatting panel for more advanced usage."
42 msgid "Type message here. Use formatting panel for more advanced usage."
43 msgstr ""
43 msgstr ""
44 "Π’Π²ΠΎΠ΄ΠΈΡ‚Π΅ сообщСниС сюда. Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉΡ‚Π΅ панСль для Π±ΠΎΠ»Π΅Π΅ слоТного форматирования."
44 "Π’Π²ΠΎΠ΄ΠΈΡ‚Π΅ сообщСниС сюда. Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉΡ‚Π΅ панСль для Π±ΠΎΠ»Π΅Π΅ слоТного форматирования."
45
45
46 #: forms.py:32
46 #: forms.py:31
47 msgid "music images i_dont_like_tags"
47 msgid "music images i_dont_like_tags"
48 msgstr "ΠΌΡƒΠ·Ρ‹ΠΊΠ° ΠΊΠ°Ρ€Ρ‚ΠΈΠ½ΠΊΠΈ Ρ‚Π΅Π³ΠΈ_Π½Π΅_Π½ΡƒΠΆΠ½Ρ‹"
48 msgstr "ΠΌΡƒΠ·Ρ‹ΠΊΠ° ΠΊΠ°Ρ€Ρ‚ΠΈΠ½ΠΊΠΈ Ρ‚Π΅Π³ΠΈ_Π½Π΅_Π½ΡƒΠΆΠ½Ρ‹"
49
49
50 #: forms.py:34
50 #: forms.py:33
51 msgid "Title"
51 msgid "Title"
52 msgstr "Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ"
52 msgstr "Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ"
53
53
54 #: forms.py:35
54 #: forms.py:34
55 msgid "Text"
55 msgid "Text"
56 msgstr "ВСкст"
56 msgstr "ВСкст"
57
57
58 #: forms.py:36
58 #: forms.py:35
59 msgid "Tag"
59 msgid "Tag"
60 msgstr "ΠœΠ΅Ρ‚ΠΊΠ°"
60 msgstr "ΠœΠ΅Ρ‚ΠΊΠ°"
61
61
62 #: forms.py:37 templates/boards/base.html:40 templates/search/search.html:7
62 #: forms.py:36 templates/boards/base.html:40 templates/search/search.html:7
63 msgid "Search"
63 msgid "Search"
64 msgstr "Поиск"
64 msgstr "Поиск"
65
65
66 #: forms.py:39
66 #: forms.py:139
67 #, python-format
68 msgid "Please wait %s seconds before sending message"
69 msgstr "ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π° ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ %s сСкунд ΠΏΠ΅Ρ€Π΅Π΄ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΎΠΉ сообщСния"
70
71 #: forms.py:140
72 msgid "File"
67 msgid "File"
73 msgstr "Π€Π°ΠΉΠ»"
68 msgstr "Π€Π°ΠΉΠ»"
74
69
75 #: forms.py:143
70 #: forms.py:142
76 msgid "File URL"
71 msgid "File URL"
77 msgstr "URL Ρ„Π°ΠΉΠ»Π°"
72 msgstr "URL Ρ„Π°ΠΉΠ»Π°"
78
73
79 #: forms.py:149
74 #: forms.py:148
80 msgid "e-mail"
75 msgid "e-mail"
81 msgstr ""
76 msgstr ""
82
77
83 #: forms.py:152
78 #: forms.py:151
84 msgid "Additional threads"
79 msgid "Additional threads"
85 msgstr "Π”ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ Ρ‚Π΅ΠΌΡ‹"
80 msgstr "Π”ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ Ρ‚Π΅ΠΌΡ‹"
86
81
87 #: forms.py:155
82 #: forms.py:162
88 msgid "Tripcode"
89 msgstr "Π’Ρ€ΠΈΠΏΠΊΠΎΠ΄"
90
91 #: forms.py:164
92 #, python-format
83 #, python-format
93 msgid "Title must have less than %s characters"
84 msgid "Title must have less than %s characters"
94 msgstr "Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ Π΄ΠΎΠ»ΠΆΠ΅Π½ ΠΈΠΌΠ΅Ρ‚ΡŒ мСньшС %s символов"
85 msgstr "Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ Π΄ΠΎΠ»ΠΆΠ΅Π½ ΠΈΠΌΠ΅Ρ‚ΡŒ мСньшС %s символов"
95
86
96 #: forms.py:174
87 #: forms.py:172
97 #, python-format
88 #, python-format
98 msgid "Text must have less than %s characters"
89 msgid "Text must have less than %s characters"
99 msgstr "ВСкст Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ ΠΊΠΎΡ€ΠΎΡ‡Π΅ %s символов"
90 msgstr "ВСкст Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ ΠΊΠΎΡ€ΠΎΡ‡Π΅ %s символов"
100
91
101 #: forms.py:194
92 #: forms.py:192
102 msgid "Invalid URL"
93 msgid "Invalid URL"
103 msgstr "НСвСрный URL"
94 msgstr "НСвСрный URL"
104
95
105 #: forms.py:215
96 #: forms.py:213
106 msgid "Invalid additional thread list"
97 msgid "Invalid additional thread list"
107 msgstr "НСвСрный список Π΄ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹Ρ… Ρ‚Π΅ΠΌ"
98 msgstr "НСвСрный список Π΄ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹Ρ… Ρ‚Π΅ΠΌ"
108
99
109 #: forms.py:251
100 #: forms.py:258
110 msgid "Either text or file must be entered."
101 msgid "Either text or file must be entered."
111 msgstr "ВСкст ΠΈΠ»ΠΈ Ρ„Π°ΠΉΠ» Π΄ΠΎΠ»ΠΆΠ½Ρ‹ Π±Ρ‹Ρ‚ΡŒ Π²Π²Π΅Π΄Π΅Π½Ρ‹."
102 msgstr "ВСкст ΠΈΠ»ΠΈ Ρ„Π°ΠΉΠ» Π΄ΠΎΠ»ΠΆΠ½Ρ‹ Π±Ρ‹Ρ‚ΡŒ Π²Π²Π΅Π΄Π΅Π½Ρ‹."
112
103
113 #: forms.py:289
104 #: forms.py:317 templates/boards/all_threads.html:148
114 #, python-format
115 msgid "File must be less than %s bytes"
116 msgstr "Π€Π°ΠΉΠ» Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ ΠΌΠ΅Π½Π΅Π΅ %s Π±Π°ΠΉΡ‚"
117
118 #: forms.py:335 templates/boards/all_threads.html:154
119 #: templates/boards/rss/post.html:10 templates/boards/tags.html:6
105 #: templates/boards/rss/post.html:10 templates/boards/tags.html:6
120 msgid "Tags"
106 msgid "Tags"
121 msgstr "ΠœΠ΅Ρ‚ΠΊΠΈ"
107 msgstr "ΠœΠ΅Ρ‚ΠΊΠΈ"
122
108
123 #: forms.py:342
109 #: forms.py:324
124 msgid "Inappropriate characters in tags."
110 msgid "Inappropriate characters in tags."
125 msgstr "НСдопустимыС символы Π² ΠΌΠ΅Ρ‚ΠΊΠ°Ρ…."
111 msgstr "НСдопустимыС символы Π² ΠΌΠ΅Ρ‚ΠΊΠ°Ρ…."
126
112
127 #: forms.py:356
113 #: forms.py:338
128 msgid "Need at least one section."
114 msgid "Need at least one section."
129 msgstr "НуТСн хотя Π±Ρ‹ ΠΎΠ΄ΠΈΠ½ Ρ€Π°Π·Π΄Π΅Π»."
115 msgstr "НуТСн хотя Π±Ρ‹ ΠΎΠ΄ΠΈΠ½ Ρ€Π°Π·Π΄Π΅Π»."
130
116
131 #: forms.py:368
117 #: forms.py:350
132 msgid "Theme"
118 msgid "Theme"
133 msgstr "Π’Π΅ΠΌΠ°"
119 msgstr "Π’Π΅ΠΌΠ°"
134
120
135 #: forms.py:369
121 #: forms.py:351
136 #| msgid "Image view mode"
137 msgid "Image view mode"
122 msgid "Image view mode"
138 msgstr "Π Π΅ΠΆΠΈΠΌ просмотра ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ"
123 msgstr "Π Π΅ΠΆΠΈΠΌ просмотра ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ"
139
124
140 #: forms.py:370
125 #: forms.py:352
141 msgid "User name"
126 msgid "User name"
142 msgstr "Имя ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ"
127 msgstr "Имя ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ"
143
128
144 #: forms.py:371
129 #: forms.py:353
145 msgid "Time zone"
130 msgid "Time zone"
146 msgstr "Часовой пояс"
131 msgstr "Часовой пояс"
147
132
148 #: forms.py:377
133 #: forms.py:359
149 msgid "Inappropriate characters."
134 msgid "Inappropriate characters."
150 msgstr "НСдопустимыС символы."
135 msgstr "НСдопустимыС символы."
151
136
152 #: templates/boards/404.html:6
137 #: templates/boards/404.html:6
153 msgid "Not found"
138 msgid "Not found"
154 msgstr "НС найдСно"
139 msgstr "НС найдСно"
155
140
156 #: templates/boards/404.html:12
141 #: templates/boards/404.html:12
157 msgid "This page does not exist"
142 msgid "This page does not exist"
158 msgstr "Π­Ρ‚ΠΎΠΉ страницы Π½Π΅ сущСствуСт"
143 msgstr "Π­Ρ‚ΠΎΠΉ страницы Π½Π΅ сущСствуСт"
159
144
160 #: templates/boards/all_threads.html:35
145 #: templates/boards/all_threads.html:35
161 msgid "Related message"
146 msgid "Related message"
162 msgstr "БвязанноС сообщСниС"
147 msgstr "БвязанноС сообщСниС"
163
148
164 #: templates/boards/all_threads.html:71
149 #: templates/boards/all_threads.html:69
165 msgid "Edit tag"
150 msgid "Edit tag"
166 msgstr "Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ ΠΌΠ΅Ρ‚ΠΊΡƒ"
151 msgstr "Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ ΠΌΠ΅Ρ‚ΠΊΡƒ"
167
152
168 #: templates/boards/all_threads.html:79
153 #: templates/boards/all_threads.html:75
169 #, python-format
154 #, python-format
170 msgid ""
155 msgid ""
171 "This tag has %(thread_count)s threads (%(active_thread_count)s active) and "
156 "This tag has %(thread_count)s threads (%(active_thread_count)s active) and "
172 "%(post_count)s posts."
157 "%(post_count)s posts."
173 msgstr ""
158 msgstr ""
174 "Π‘ этой ΠΌΠ΅Ρ‚ΠΊΠΎΠΉ Π΅ΡΡ‚ΡŒ %(thread_count)s Ρ‚Π΅ΠΌ (%(active_thread_count)s Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Ρ…) ΠΈ "
159 "Π‘ этой ΠΌΠ΅Ρ‚ΠΊΠΎΠΉ Π΅ΡΡ‚ΡŒ %(thread_count)s Ρ‚Π΅ΠΌ (%(active_thread_count)s Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Ρ…) ΠΈ "
175 "%(post_count)s сообщСний."
160 "%(post_count)s сообщСний."
176
161
177 #: templates/boards/all_threads.html:81
162 #: templates/boards/all_threads.html:77
178 msgid "Related tags:"
163 msgid "Related tags:"
179 msgstr "ΠŸΠΎΡ…ΠΎΠΆΠΈΠ΅ ΠΌΠ΅Ρ‚ΠΊΠΈ:"
164 msgstr "ΠŸΠΎΡ…ΠΎΠΆΠΈΠ΅ ΠΌΠ΅Ρ‚ΠΊΠΈ:"
180
165
181 #: templates/boards/all_threads.html:96 templates/boards/feed.html:30
166 #: templates/boards/all_threads.html:90 templates/boards/feed.html:30
182 #: templates/boards/notifications.html:17 templates/search/search.html:26
167 #: templates/boards/notifications.html:17 templates/search/search.html:26
183 msgid "Previous page"
168 msgid "Previous page"
184 msgstr "ΠŸΡ€Π΅Π΄Ρ‹Π΄ΡƒΡ‰Π°Ρ страница"
169 msgstr "ΠŸΡ€Π΅Π΄Ρ‹Π΄ΡƒΡ‰Π°Ρ страница"
185
170
186 #: templates/boards/all_threads.html:110
171 #: templates/boards/all_threads.html:104
187 #, python-format
172 #, python-format
188 msgid "Skipped %(count)s replies. Open thread to see all replies."
173 #| msgid "Skipped %(count)s replies. Open thread to see all replies."
189 msgstr "ΠŸΡ€ΠΎΠΏΡƒΡ‰Π΅Π½ΠΎ %(count)s ΠΎΡ‚Π²Π΅Ρ‚ΠΎΠ². ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ Ρ‚Ρ€Π΅Π΄, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠ²ΠΈΠ΄Π΅Ρ‚ΡŒ всС ΠΎΡ‚Π²Π΅Ρ‚Ρ‹."
174 msgid "Skipped %(count)s reply. Open thread to see all replies."
175 msgid_plural "Skipped %(count)s replies. Open thread to see all replies."
176 msgstr[0] ""
177 "ΠŸΡ€ΠΎΠΏΡƒΡ‰Π΅Π½ %(count)s ΠΎΡ‚Π²Π΅Ρ‚. ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ Ρ‚Ρ€Π΅Π΄, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠ²ΠΈΠ΄Π΅Ρ‚ΡŒ всС ΠΎΡ‚Π²Π΅Ρ‚Ρ‹."
178 msgstr[1] ""
179 "ΠŸΡ€ΠΎΠΏΡƒΡ‰Π΅Π½ΠΎ %(count)s ΠΎΡ‚Π²Π΅Ρ‚Π°. ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ Ρ‚Ρ€Π΅Π΄, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠ²ΠΈΠ΄Π΅Ρ‚ΡŒ всС ΠΎΡ‚Π²Π΅Ρ‚Ρ‹."
180 msgstr[2] ""
181 "ΠŸΡ€ΠΎΠΏΡƒΡ‰Π΅Π½ΠΎ %(count)s ΠΎΡ‚Π²Π΅Ρ‚ΠΎΠ². ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ Ρ‚Ρ€Π΅Π΄, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠ²ΠΈΠ΄Π΅Ρ‚ΡŒ всС ΠΎΡ‚Π²Π΅Ρ‚Ρ‹."
190
182
191 #: templates/boards/all_threads.html:128 templates/boards/feed.html:40
183 #: templates/boards/all_threads.html:122 templates/boards/feed.html:40
192 #: templates/boards/notifications.html:27 templates/search/search.html:37
184 #: templates/boards/notifications.html:27 templates/search/search.html:37
193 msgid "Next page"
185 msgid "Next page"
194 msgstr "Π‘Π»Π΅Π΄ΡƒΡŽΡ‰Π°Ρ страница"
186 msgstr "Π‘Π»Π΅Π΄ΡƒΡŽΡ‰Π°Ρ страница"
195
187
196 #: templates/boards/all_threads.html:133
188 #: templates/boards/all_threads.html:127
197 msgid "No threads exist. Create the first one!"
189 msgid "No threads exist. Create the first one!"
198 msgstr "НСт Ρ‚Π΅ΠΌ. Π‘ΠΎΠ·Π΄Π°ΠΉΡ‚Π΅ ΠΏΠ΅Ρ€Π²ΡƒΡŽ!"
190 msgstr "НСт Ρ‚Π΅ΠΌ. Π‘ΠΎΠ·Π΄Π°ΠΉΡ‚Π΅ ΠΏΠ΅Ρ€Π²ΡƒΡŽ!"
199
191
200 #: templates/boards/all_threads.html:139
192 #: templates/boards/all_threads.html:133
201 msgid "Create new thread"
193 msgid "Create new thread"
202 msgstr "Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ Π½ΠΎΠ²ΡƒΡŽ Ρ‚Π΅ΠΌΡƒ"
194 msgstr "Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ Π½ΠΎΠ²ΡƒΡŽ Ρ‚Π΅ΠΌΡƒ"
203
195
204 #: templates/boards/all_threads.html:144 templates/boards/preview.html:16
196 #: templates/boards/all_threads.html:138 templates/boards/preview.html:16
205 #: templates/boards/thread_normal.html:38
197 #: templates/boards/thread_normal.html:51
206 msgid "Post"
198 msgid "Post"
207 msgstr "ΠžΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ"
199 msgstr "ΠžΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ"
208
200
209 #: templates/boards/all_threads.html:149
201 #: templates/boards/all_threads.html:139 templates/boards/preview.html:6
202 #: templates/boards/staticpages/help.html:21
203 #: templates/boards/thread_normal.html:52
204 msgid "Preview"
205 msgstr "ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€"
206
207 #: templates/boards/all_threads.html:144
210 msgid "Tags must be delimited by spaces. Text or image is required."
208 msgid "Tags must be delimited by spaces. Text or image is required."
211 msgstr ""
209 msgstr ""
212 "ΠœΠ΅Ρ‚ΠΊΠΈ Π΄ΠΎΠ»ΠΆΠ½Ρ‹ Π±Ρ‹Ρ‚ΡŒ Ρ€Π°Π·Π΄Π΅Π»Π΅Π½Ρ‹ ΠΏΡ€ΠΎΠ±Π΅Π»Π°ΠΌΠΈ. ВСкст ΠΈΠ»ΠΈ ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹."
210 "ΠœΠ΅Ρ‚ΠΊΠΈ Π΄ΠΎΠ»ΠΆΠ½Ρ‹ Π±Ρ‹Ρ‚ΡŒ Ρ€Π°Π·Π΄Π΅Π»Π΅Π½Ρ‹ ΠΏΡ€ΠΎΠ±Π΅Π»Π°ΠΌΠΈ. ВСкст ΠΈΠ»ΠΈ ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹."
213
211
214 #: templates/boards/all_threads.html:151 templates/boards/preview.html:6
212 #: templates/boards/all_threads.html:147 templates/boards/thread_normal.html:58
215 #: templates/boards/staticpages/help.html:21
216 #: templates/boards/thread_normal.html:42
217 msgid "Preview"
218 msgstr "ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€"
219
220 #: templates/boards/all_threads.html:153 templates/boards/thread_normal.html:45
221 msgid "Text syntax"
213 msgid "Text syntax"
222 msgstr "Бинтаксис тСкста"
214 msgstr "Бинтаксис тСкста"
223
215
224 #: templates/boards/all_threads.html:167 templates/boards/feed.html:53
216 #: templates/boards/all_threads.html:161 templates/boards/feed.html:53
225 msgid "Pages:"
217 msgid "Pages:"
226 msgstr "Π‘Ρ‚Ρ€Π°Π½ΠΈΡ†Ρ‹: "
218 msgstr "Π‘Ρ‚Ρ€Π°Π½ΠΈΡ†Ρ‹: "
227
219
228 #: templates/boards/authors.html:6 templates/boards/authors.html.py:12
220 #: templates/boards/authors.html:6 templates/boards/authors.html.py:12
229 msgid "Authors"
221 msgid "Authors"
230 msgstr "Авторы"
222 msgstr "Авторы"
231
223
232 #: templates/boards/authors.html:26
224 #: templates/boards/authors.html:26
233 msgid "Distributed under the"
225 msgid "Distributed under the"
234 msgstr "РаспространяСтся ΠΏΠΎΠ΄"
226 msgstr "РаспространяСтся ΠΏΠΎΠ΄"
235
227
236 #: templates/boards/authors.html:28
228 #: templates/boards/authors.html:28
237 msgid "license"
229 msgid "license"
238 msgstr "Π»ΠΈΡ†Π΅Π½Π·ΠΈΠ΅ΠΉ"
230 msgstr "Π»ΠΈΡ†Π΅Π½Π·ΠΈΠ΅ΠΉ"
239
231
240 #: templates/boards/authors.html:30
232 #: templates/boards/authors.html:30
241 msgid "Repository"
233 msgid "Repository"
242 msgstr "Π Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€ΠΈΠΉ"
234 msgstr "Π Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€ΠΈΠΉ"
243
235
244 #: templates/boards/base.html:14 templates/boards/base.html.py:41
236 #: templates/boards/base.html:14 templates/boards/base.html.py:41
245 msgid "Feed"
237 msgid "Feed"
246 msgstr "Π›Π΅Π½Ρ‚Π°"
238 msgstr "Π›Π΅Π½Ρ‚Π°"
247
239
248 #: templates/boards/base.html:31
240 #: templates/boards/base.html:31
249 msgid "All threads"
241 msgid "All threads"
250 msgstr "ВсС Ρ‚Π΅ΠΌΡ‹"
242 msgstr "ВсС Ρ‚Π΅ΠΌΡ‹"
251
243
252 #: templates/boards/base.html:37
244 #: templates/boards/base.html:37
253 msgid "Add tags"
245 msgid "Add tags"
254 msgstr "Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΌΠ΅Ρ‚ΠΊΠΈ"
246 msgstr "Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΌΠ΅Ρ‚ΠΊΠΈ"
255
247
256 #: templates/boards/base.html:39
248 #: templates/boards/base.html:39
257 msgid "Tag management"
249 msgid "Tag management"
258 msgstr "Π£ΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ ΠΌΠ΅Ρ‚ΠΊΠ°ΠΌΠΈ"
250 msgstr "Π£ΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ ΠΌΠ΅Ρ‚ΠΊΠ°ΠΌΠΈ"
259
251
260 #: templates/boards/base.html:39
252 #: templates/boards/base.html:39
261 msgid "tags"
253 msgid "tags"
262 msgstr "ΠΌΠ΅Ρ‚ΠΊΠΈ"
254 msgstr "ΠΌΠ΅Ρ‚ΠΊΠΈ"
263
255
264 #: templates/boards/base.html:40
256 #: templates/boards/base.html:40
265 msgid "search"
257 msgid "search"
266 msgstr "поиск"
258 msgstr "поиск"
267
259
268 #: templates/boards/base.html:41 templates/boards/feed.html:11
260 #: templates/boards/base.html:41 templates/boards/feed.html:11
269 msgid "feed"
261 msgid "feed"
270 msgstr "Π»Π΅Π½Ρ‚Π°"
262 msgstr "Π»Π΅Π½Ρ‚Π°"
271
263
272 #: templates/boards/base.html:42 templates/boards/random.html:6
264 #: templates/boards/base.html:42 templates/boards/random.html:6
273 msgid "Random images"
265 msgid "Random images"
274 msgstr "Π‘Π»ΡƒΡ‡Π°ΠΉΠ½Ρ‹Π΅ изобраТСния"
266 msgstr "Π‘Π»ΡƒΡ‡Π°ΠΉΠ½Ρ‹Π΅ изобраТСния"
275
267
276 #: templates/boards/base.html:42
268 #: templates/boards/base.html:42
277 msgid "random"
269 msgid "random"
278 msgstr "случайныС"
270 msgstr "случайныС"
279
271
280 #: templates/boards/base.html:45 templates/boards/base.html.py:46
272 #: templates/boards/base.html:45 templates/boards/base.html.py:46
281 #: templates/boards/notifications.html:8
273 #: templates/boards/notifications.html:8
282 msgid "Notifications"
274 msgid "Notifications"
283 msgstr "УвСдомлСния"
275 msgstr "УвСдомлСния"
284
276
285 #: templates/boards/base.html:53 templates/boards/settings.html:8
277 #: templates/boards/base.html:53 templates/boards/settings.html:8
286 msgid "Settings"
278 msgid "Settings"
287 msgstr "Настройки"
279 msgstr "Настройки"
288
280
289 #: templates/boards/base.html:66
281 #: templates/boards/base.html:79
290 msgid "Admin"
282 msgid "Admin"
291 msgstr "АдминистрированиС"
283 msgstr "АдминистрированиС"
292
284
293 #: templates/boards/base.html:68
285 #: templates/boards/base.html:81
294 #, python-format
286 #, python-format
295 msgid "Speed: %(ppd)s posts per day"
287 msgid "Speed: %(ppd)s posts per day"
296 msgstr "Π‘ΠΊΠΎΡ€ΠΎΡΡ‚ΡŒ: %(ppd)s сообщСний Π² дСнь"
288 msgstr "Π‘ΠΊΠΎΡ€ΠΎΡΡ‚ΡŒ: %(ppd)s сообщСний Π² дСнь"
297
289
298 #: templates/boards/base.html:70
290 #: templates/boards/base.html:83
299 msgid "Up"
291 msgid "Up"
300 msgstr "Π’Π²Π΅Ρ€Ρ…"
292 msgstr "Π’Π²Π΅Ρ€Ρ…"
301
293
302 #: templates/boards/feed.html:45
294 #: templates/boards/feed.html:45
303 msgid "No posts exist. Create the first one!"
295 msgid "No posts exist. Create the first one!"
304 msgstr "НСт сообщСний. Π‘ΠΎΠ·Π΄Π°ΠΉΡ‚Π΅ ΠΏΠ΅Ρ€Π²ΠΎΠ΅!"
296 msgstr "НСт сообщСний. Π‘ΠΎΠ·Π΄Π°ΠΉΡ‚Π΅ ΠΏΠ΅Ρ€Π²ΠΎΠ΅!"
305
297
306 #: templates/boards/post.html:30
298 #: templates/boards/post.html:32
307 msgid "Open"
299 msgid "Open"
308 msgstr "ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ"
300 msgstr "ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ"
309
301
310 #: templates/boards/post.html:32 templates/boards/post.html.py:43
302 #: templates/boards/post.html:34 templates/boards/post.html.py:45
311 msgid "Reply"
303 msgid "Reply"
312 msgstr "ΠžΡ‚Π²Π΅Ρ‚ΠΈΡ‚ΡŒ"
304 msgstr "ΠžΡ‚Π²Π΅Ρ‚ΠΈΡ‚ΡŒ"
313
305
314 #: templates/boards/post.html:38
306 #: templates/boards/post.html:40
315 msgid " in "
307 msgid " in "
316 msgstr " Π² "
308 msgstr " Π² "
317
309
318 #: templates/boards/post.html:48
310 #: templates/boards/post.html:50
319 msgid "Edit"
311 msgid "Edit"
320 msgstr "Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ"
312 msgstr "Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ"
321
313
322 #: templates/boards/post.html:50
314 #: templates/boards/post.html:52
323 msgid "Edit thread"
315 msgid "Edit thread"
324 msgstr "Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ Ρ‚Π΅ΠΌΡƒ"
316 msgstr "Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ Ρ‚Π΅ΠΌΡƒ"
325
317
326 #: templates/boards/post.html:97
318 #: templates/boards/post.html:94
327 msgid "Replies"
319 msgid "Replies"
328 msgstr "ΠžΡ‚Π²Π΅Ρ‚Ρ‹"
320 msgstr "ΠžΡ‚Π²Π΅Ρ‚Ρ‹"
329
321
330 #: templates/boards/post.html:109 templates/boards/thread.html:34
322 #: templates/boards/post.html:105
331 msgid "messages"
323 #, python-format
332 msgstr "сообщСний"
324 msgid "%(count)s message"
325 msgid_plural "%(count)s messages"
326 msgstr[0] "%(count)s сообщСниС"
327 msgstr[1] "%(count)s сообщСния"
328 msgstr[2] "%(count)s сообщСний"
333
329
334 #: templates/boards/post.html:110 templates/boards/thread.html:35
330 #, python-format
335 msgid "images"
331 msgid "Please wait %(delay)d second before sending message"
336 msgstr "ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ"
332 msgid_plural "Please wait %(delay)d seconds before sending message"
333 msgstr[0] "ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π° ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ %(delay)d сСкунду ΠΏΠ΅Ρ€Π΅Π΄ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΎΠΉ сообщСния"
334 msgstr[1] "ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π° ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ %(delay)d сСкунды ΠΏΠ΅Ρ€Π΅Π΄ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΎΠΉ сообщСния"
335 msgstr[2] "ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π° ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ %(delay)d сСкунд ΠΏΠ΅Ρ€Π΅Π΄ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΎΠΉ сообщСния"
336
337 #: templates/boards/post.html:106
338 #, python-format
339 msgid "%(count)s image"
340 msgid_plural "%(count)s images"
341 msgstr[0] "%(count)s ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅"
342 msgstr[1] "%(count)s изобраТСния"
343 msgstr[2] "%(count)s ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ"
337
344
338 #: templates/boards/rss/post.html:5
345 #: templates/boards/rss/post.html:5
339 msgid "Post image"
346 msgid "Post image"
340 msgstr "Π˜Π·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ сообщСния"
347 msgstr "Π˜Π·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ сообщСния"
341
348
342 #: templates/boards/settings.html:15
349 #: templates/boards/settings.html:15
343 msgid "You are moderator."
350 msgid "You are moderator."
344 msgstr "Π’Ρ‹ ΠΌΠΎΠ΄Π΅Ρ€Π°Ρ‚ΠΎΡ€."
351 msgstr "Π’Ρ‹ ΠΌΠΎΠ΄Π΅Ρ€Π°Ρ‚ΠΎΡ€."
345
352
346 #: templates/boards/settings.html:19
353 #: templates/boards/settings.html:19
347 msgid "Hidden tags:"
354 msgid "Hidden tags:"
348 msgstr "Π‘ΠΊΡ€Ρ‹Ρ‚Ρ‹Π΅ ΠΌΠ΅Ρ‚ΠΊΠΈ:"
355 msgstr "Π‘ΠΊΡ€Ρ‹Ρ‚Ρ‹Π΅ ΠΌΠ΅Ρ‚ΠΊΠΈ:"
349
356
350 #: templates/boards/settings.html:27
357 #: templates/boards/settings.html:25
351 msgid "No hidden tags."
358 msgid "No hidden tags."
352 msgstr "НСт скрытых ΠΌΠ΅Ρ‚ΠΎΠΊ."
359 msgstr "НСт скрытых ΠΌΠ΅Ρ‚ΠΎΠΊ."
353
360
354 #: templates/boards/settings.html:29
361 #: templates/boards/settings.html:34
355 msgid "Tripcode:"
356 msgstr "Π’Ρ€ΠΈΠΏΠΊΠΎΠ΄:"
357
358 #: templates/boards/settings.html:29
359 msgid "reset"
360 msgstr "ΡΠ±Ρ€ΠΎΡΠΈΡ‚ΡŒ"
361
362 #: templates/boards/settings.html:37
363 msgid "Save"
362 msgid "Save"
364 msgstr "Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ"
363 msgstr "Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ"
365
364
366 #: templates/boards/staticpages/banned.html:6
365 #: templates/boards/staticpages/banned.html:6
367 msgid "Banned"
366 msgid "Banned"
368 msgstr "Π—Π°Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²Π°Π½"
367 msgstr "Π—Π°Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²Π°Π½"
369
368
370 #: templates/boards/staticpages/banned.html:11
369 #: templates/boards/staticpages/banned.html:11
371 msgid "Your IP address has been banned. Contact the administrator"
370 msgid "Your IP address has been banned. Contact the administrator"
372 msgstr "Π’Π°Ρˆ IP адрСс Π±Ρ‹Π» Π·Π°Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²Π°Π½. Π‘Π²ΡΠΆΠΈΡ‚Π΅ΡΡŒ с администратором"
371 msgstr "Π’Π°Ρˆ IP адрСс Π±Ρ‹Π» Π·Π°Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²Π°Π½. Π‘Π²ΡΠΆΠΈΡ‚Π΅ΡΡŒ с администратором"
373
372
374 #: templates/boards/staticpages/help.html:6
373 #: templates/boards/staticpages/help.html:6
375 #: templates/boards/staticpages/help.html:10
374 #: templates/boards/staticpages/help.html:10
376 msgid "Syntax"
375 msgid "Syntax"
377 msgstr "Бинтаксис"
376 msgstr "Бинтаксис"
378
377
379 #: templates/boards/staticpages/help.html:11
378 #: templates/boards/staticpages/help.html:11
380 msgid "Italic text"
379 msgid "Italic text"
381 msgstr "ΠšΡƒΡ€ΡΠΈΠ²Π½Ρ‹ΠΉ тСкст"
380 msgstr "ΠšΡƒΡ€ΡΠΈΠ²Π½Ρ‹ΠΉ тСкст"
382
381
383 #: templates/boards/staticpages/help.html:12
382 #: templates/boards/staticpages/help.html:12
384 msgid "Bold text"
383 msgid "Bold text"
385 msgstr "ΠŸΠΎΠ»ΡƒΠΆΠΈΡ€Π½Ρ‹ΠΉ тСкст"
384 msgstr "ΠŸΠΎΠ»ΡƒΠΆΠΈΡ€Π½Ρ‹ΠΉ тСкст"
386
385
387 #: templates/boards/staticpages/help.html:13
386 #: templates/boards/staticpages/help.html:13
388 msgid "Spoiler"
387 msgid "Spoiler"
389 msgstr "Π‘ΠΏΠΎΠΉΠ»Π΅Ρ€"
388 msgstr "Π‘ΠΏΠΎΠΉΠ»Π΅Ρ€"
390
389
391 #: templates/boards/staticpages/help.html:14
390 #: templates/boards/staticpages/help.html:14
392 msgid "Link to a post"
391 msgid "Link to a post"
393 msgstr "Бсылка Π½Π° сообщСниС"
392 msgstr "Бсылка Π½Π° сообщСниС"
394
393
395 #: templates/boards/staticpages/help.html:15
394 #: templates/boards/staticpages/help.html:15
396 msgid "Strikethrough text"
395 msgid "Strikethrough text"
397 msgstr "Π—Π°Ρ‡Π΅Ρ€ΠΊΠ½ΡƒΡ‚Ρ‹ΠΉ тСкст"
396 msgstr "Π—Π°Ρ‡Π΅Ρ€ΠΊΠ½ΡƒΡ‚Ρ‹ΠΉ тСкст"
398
397
399 #: templates/boards/staticpages/help.html:16
398 #: templates/boards/staticpages/help.html:16
400 msgid "Comment"
399 msgid "Comment"
401 msgstr "ΠšΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠΉ"
400 msgstr "ΠšΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠΉ"
402
401
403 #: templates/boards/staticpages/help.html:17
402 #: templates/boards/staticpages/help.html:17
404 #: templates/boards/staticpages/help.html:18
403 #: templates/boards/staticpages/help.html:18
405 msgid "Quote"
404 msgid "Quote"
406 msgstr "Π¦ΠΈΡ‚Π°Ρ‚Π°"
405 msgstr "Π¦ΠΈΡ‚Π°Ρ‚Π°"
407
406
408 #: templates/boards/staticpages/help.html:21
407 #: templates/boards/staticpages/help.html:21
409 msgid "You can try pasting the text and previewing the result here:"
408 msgid "You can try pasting the text and previewing the result here:"
410 msgstr "Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΏΠΎΠΏΡ€ΠΎΠ±ΠΎΠ²Π°Ρ‚ΡŒ Π²ΡΡ‚Π°Π²ΠΈΡ‚ΡŒ тСкст ΠΈ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΈΡ‚ΡŒ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ здСсь:"
409 msgstr "Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΏΠΎΠΏΡ€ΠΎΠ±ΠΎΠ²Π°Ρ‚ΡŒ Π²ΡΡ‚Π°Π²ΠΈΡ‚ΡŒ тСкст ΠΈ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΈΡ‚ΡŒ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ здСсь:"
411
410
412 #: templates/boards/tags.html:17
411 #: templates/boards/tags.html:17
413 msgid "Sections:"
412 msgid "Sections:"
414 msgstr "Π Π°Π·Π΄Π΅Π»Ρ‹:"
413 msgstr "Π Π°Π·Π΄Π΅Π»Ρ‹:"
415
414
416 #: templates/boards/tags.html:30
415 #: templates/boards/tags.html:30
417 msgid "Other tags:"
416 msgid "Other tags:"
418 msgstr "Π”Ρ€ΡƒΠ³ΠΈΠ΅ ΠΌΠ΅Ρ‚ΠΊΠΈ:"
417 msgstr "Π”Ρ€ΡƒΠ³ΠΈΠ΅ ΠΌΠ΅Ρ‚ΠΊΠΈ:"
419
418
420 #: templates/boards/tags.html:43
419 #: templates/boards/tags.html:43
421 msgid "All tags..."
420 msgid "All tags..."
422 msgstr "ВсС ΠΌΠ΅Ρ‚ΠΊΠΈ..."
421 msgstr "ВсС ΠΌΠ΅Ρ‚ΠΊΠΈ..."
423
422
424 #: templates/boards/thread.html:15
423 #: templates/boards/thread.html:15
425 msgid "Normal"
424 msgid "Normal"
426 msgstr "ΠΠΎΡ€ΠΌΠ°Π»ΡŒΠ½Ρ‹ΠΉ"
425 msgstr "ΠΠΎΡ€ΠΌΠ°Π»ΡŒΠ½Ρ‹ΠΉ"
427
426
428 #: templates/boards/thread.html:16
427 #: templates/boards/thread.html:16
429 msgid "Gallery"
428 msgid "Gallery"
430 msgstr "ГалСрСя"
429 msgstr "ГалСрСя"
431
430
432 #: templates/boards/thread.html:17
431 #: templates/boards/thread.html:17
433 msgid "Tree"
432 msgid "Tree"
434 msgstr "Π”Π΅Ρ€Π΅Π²ΠΎ"
433 msgstr "Π”Π΅Ρ€Π΅Π²ΠΎ"
435
434
436 #: templates/boards/thread.html:36
435 #: templates/boards/thread.html:36
436 msgid "message"
437 msgid_plural "messages"
438 msgstr[0] "сообщСниС"
439 msgstr[1] "сообщСния"
440 msgstr[2] "сообщСний"
441
442 #: templates/boards/thread.html:39
443 msgid "image"
444 msgid_plural "images"
445 msgstr[0] "ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅"
446 msgstr[1] "изобраТСния"
447 msgstr[2] "ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ"
448
449 #: templates/boards/thread.html:41
437 msgid "Last update: "
450 msgid "Last update: "
438 msgstr "ПослСднСС обновлСниС: "
451 msgstr "ПослСднСС обновлСниС: "
439
452
440 #: templates/boards/thread_gallery.html:36
453 #: templates/boards/thread_gallery.html:36
441 msgid "No images."
454 msgid "No images."
442 msgstr "НСт ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ."
455 msgstr "НСт ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ."
443
456
444 #: templates/boards/thread_normal.html:17
457 #: templates/boards/thread_normal.html:30
445 msgid "posts to bumplimit"
458 msgid "posts to bumplimit"
446 msgstr "сообщСний Π΄ΠΎ Π±Π°ΠΌΠΏΠ»ΠΈΠΌΠΈΡ‚Π°"
459 msgstr "сообщСний Π΄ΠΎ Π±Π°ΠΌΠΏΠ»ΠΈΠΌΠΈΡ‚Π°"
447
460
448 #: templates/boards/thread_normal.html:31
461 #: templates/boards/thread_normal.html:44
449 msgid "Reply to thread"
462 msgid "Reply to thread"
450 msgstr "ΠžΡ‚Π²Π΅Ρ‚ΠΈΡ‚ΡŒ Π² Ρ‚Π΅ΠΌΡƒ"
463 msgstr "ΠžΡ‚Π²Π΅Ρ‚ΠΈΡ‚ΡŒ Π² Ρ‚Π΅ΠΌΡƒ"
451
464
452 #: templates/boards/thread_normal.html:31
465 #: templates/boards/thread_normal.html:44
453 msgid "to message "
466 msgid "to message "
454 msgstr "Π½Π° сообщСниС"
467 msgstr "Π½Π° сообщСниС"
455
468
456 #: templates/boards/thread_normal.html:46
469 #: templates/boards/thread_normal.html:59
457 msgid "Close form"
470 msgid "Close form"
458 msgstr "Π—Π°ΠΊΡ€Ρ‹Ρ‚ΡŒ Ρ„ΠΎΡ€ΠΌΡƒ"
471 msgstr "Π—Π°ΠΊΡ€Ρ‹Ρ‚ΡŒ Ρ„ΠΎΡ€ΠΌΡƒ"
459
472
460 #: templates/search/search.html:17
473 #: templates/search/search.html:17
461 msgid "Ok"
474 msgid "Ok"
462 msgstr "Ок"
475 msgstr "Ок"
476
477 #: utils.py:102
478 #, python-format
479 msgid "File must be less than %s bytes"
480 msgstr "Π€Π°ΠΉΠ» Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ ΠΌΠ΅Π½Π΅Π΅ %s Π±Π°ΠΉΡ‚"
481
482 msgid "favorites"
483 msgstr "ΠΈΠ·Π±Ρ€Π°Π½Π½ΠΎΠ΅"
484
485 msgid "Loading..."
486 msgstr "Π—Π°Π³Ρ€ΡƒΠ·ΠΊΠ°..."
487
488 msgid "Category:"
489 msgstr "ΠšΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΡ:"
490
491 msgid "Subcategories:"
492 msgstr "ΠŸΠΎΠ΄ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ:"
1 NO CONTENT: modified file, binary diff hidden
NO CONTENT: modified file, binary diff hidden
@@ -1,32 +1,55 b''
1 # SOME DESCRIPTIVE TITLE.
1 # SOME DESCRIPTIVE TITLE.
2 # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
2 # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 # This file is distributed under the same license as the PACKAGE package.
3 # This file is distributed under the same license as the PACKAGE package.
4 # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
4 # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
5 #
5 #
6 #, fuzzy
6 #, fuzzy
7 msgid ""
7 msgid ""
8 msgstr ""
8 msgstr ""
9 "Project-Id-Version: PACKAGE VERSION\n"
9 "Project-Id-Version: PACKAGE VERSION\n"
10 "Report-Msgid-Bugs-To: \n"
10 "Report-Msgid-Bugs-To: \n"
11 "POT-Creation-Date: 2014-07-02 13:26+0300\n"
11 "POT-Creation-Date: 2015-09-04 18:47+0300\n"
12 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
12 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
13 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14 "Language-Team: LANGUAGE <LL@li.org>\n"
14 "Language-Team: LANGUAGE <LL@li.org>\n"
15 "Language: \n"
15 "Language: \n"
16 "MIME-Version: 1.0\n"
16 "MIME-Version: 1.0\n"
17 "Content-Type: text/plain; charset=UTF-8\n"
17 "Content-Type: text/plain; charset=UTF-8\n"
18 "Content-Transfer-Encoding: 8bit\n"
18 "Content-Transfer-Encoding: 8bit\n"
19 "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
19 "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
20 "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
20 "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
21
21
22 #: static/js/refpopup.js:58
22 #: static/js/3party/jquery-ui.min.js:8
23 msgid "'"
24 msgstr ""
25
26 #: static/js/refpopup.js:72
23 msgid "Loading..."
27 msgid "Loading..."
24 msgstr "Π—Π°Π³Ρ€ΡƒΠ·ΠΊΠ°..."
28 msgstr "Π—Π°Π³Ρ€ΡƒΠ·ΠΊΠ°..."
25
29
26 #: static/js/refpopup.js:77
30 #: static/js/refpopup.js:91
27 msgid "Post not found"
31 msgid "Post not found"
28 msgstr "Π‘ΠΎΠΎΠ±Ρ‰Π΅Π½ΠΈΠ΅ Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½ΠΎ"
32 msgstr "Π‘ΠΎΠΎΠ±Ρ‰Π΅Π½ΠΈΠ΅ Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½ΠΎ"
29
33
30 #: static/js/thread_update.js:279
34 #: static/js/thread_update.js:261
35 msgid "message"
36 msgid_plural "messages"
37 msgstr[0] "сообщСниС"
38 msgstr[1] "сообщСния"
39 msgstr[2] "сообщСний"
40
41 #: static/js/thread_update.js:262
42 msgid "image"
43 msgid_plural "images"
44 msgstr[0] "ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅"
45 msgstr[1] "изобраТСния"
46 msgstr[2] "ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ"
47
48 #: static/js/thread_update.js:445
31 msgid "Sending message..."
49 msgid "Sending message..."
32 msgstr "ΠžΡ‚ΠΏΡ€Π°Π²ΠΊΠ° сообщСния..." No newline at end of file
50 msgstr "ΠžΡ‚ΠΏΡ€Π°Π²ΠΊΠ° сообщСния..."
51
52 #: static/js/thread_update.js:449
53 msgid "Server error!"
54 msgstr "Ошибка сСрвСра!"
55
@@ -1,21 +1,28 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
2 from __future__ import unicode_literals
3
3
4 from django.db import migrations
4 from django.db import migrations
5
5
6
6
7 class Migration(migrations.Migration):
7 class Migration(migrations.Migration):
8
8
9 def refuild_refmap(apps, schema_editor):
9 def rebuild_refmap(apps, schema_editor):
10 Post = apps.get_model('boards', 'Post')
10 Post = apps.get_model('boards', 'Post')
11 for post in Post.objects.all():
11 for post in Post.objects.all():
12 post.build_refmap()
12 refposts = list()
13 for refpost in post.referenced_posts.all():
14 result = '<a href="{}">&gt;&gt;{}</a>'.format(refpost.get_absolute_url(),
15 self.id)
16 if refpost.is_opening():
17 result = '<b>{}</b>'.format(result)
18 refposts += result
19 post.refmap = ', '.join(refposts)
13 post.save(update_fields=['refmap'])
20 post.save(update_fields=['refmap'])
14
21
15 dependencies = [
22 dependencies = [
16 ('boards', '0024_post_tripcode'),
23 ('boards', '0024_post_tripcode'),
17 ]
24 ]
18
25
19 operations = [
26 operations = [
20 migrations.RunPython(refuild_refmap),
27 migrations.RunPython(rebuild_refmap),
21 ]
28 ]
@@ -1,70 +1,84 b''
1 from django.template.defaultfilters import filesizeformat
1 from django.template.defaultfilters import filesizeformat
2 from django.contrib.staticfiles.templatetags.staticfiles import static
2 from django.contrib.staticfiles.templatetags.staticfiles import static
3
3
4 FILE_STUB_IMAGE = 'images/file.png'
4 FILE_STUB_IMAGE = 'images/file.png'
5
5
6 FILE_TYPES_VIDEO = (
6 FILE_TYPES_VIDEO = (
7 'webm',
7 'webm',
8 'mp4',
8 'mp4',
9 'mpeg',
9 )
10 )
10 FILE_TYPE_SVG = 'svg'
11 FILE_TYPE_SVG = 'svg'
11 FILE_TYPES_AUDIO = (
12 FILE_TYPES_AUDIO = (
12 'ogg',
13 'ogg',
13 'mp3',
14 'mp3',
15 'opus',
14 )
16 )
15
17
18 PLAIN_FILE_FORMATS = {
19 'pdf': 'pdf',
20 'djvu': 'djvu',
21 'txt': 'txt',
22 }
23
16
24
17 def get_viewers():
25 def get_viewers():
18 return AbstractViewer.__subclasses__()
26 return AbstractViewer.__subclasses__()
19
27
20
28
21 class AbstractViewer:
29 class AbstractViewer:
22 def __init__(self, file, file_type):
30 def __init__(self, file, file_type):
23 self.file = file
31 self.file = file
24 self.file_type = file_type
32 self.file_type = file_type
25
33
26 @staticmethod
34 @staticmethod
27 def supports(file_type):
35 def supports(file_type):
28 return True
36 return True
29
37
30 def get_view(self):
38 def get_view(self):
31 return '<div class="image">'\
39 return '<div class="image">'\
32 '{}'\
40 '{}'\
33 '<div class="image-metadata"><a href="{}" download >{}, {}</a></div>'\
41 '<div class="image-metadata"><a href="{}" download >{}, {}</a></div>'\
34 '</div>'.format(self.get_format_view(), self.file.url,
42 '</div>'.format(self.get_format_view(), self.file.url,
35 self.file_type, filesizeformat(self.file.size))
43 self.file_type, filesizeformat(self.file.size))
36
44
37 def get_format_view(self):
45 def get_format_view(self):
46 if self.file_type in PLAIN_FILE_FORMATS:
47 image = 'images/fileformats/{}.png'.format(
48 PLAIN_FILE_FORMATS[self.file_type])
49 else:
50 image = FILE_STUB_IMAGE
51
38 return '<a href="{}">'\
52 return '<a href="{}">'\
39 '<img src="{}" width="200" height="150"/>'\
53 '<img src="{}" width="200" height="150"/>'\
40 '</a>'.format(self.file.url, static(FILE_STUB_IMAGE))
54 '</a>'.format(self.file.url, static(image))
41
55
42
56
43 class VideoViewer(AbstractViewer):
57 class VideoViewer(AbstractViewer):
44 @staticmethod
58 @staticmethod
45 def supports(file_type):
59 def supports(file_type):
46 return file_type in FILE_TYPES_VIDEO
60 return file_type in FILE_TYPES_VIDEO
47
61
48 def get_format_view(self):
62 def get_format_view(self):
49 return '<video width="200" height="150" controls src="{}"></video>'\
63 return '<video width="200" height="150" controls src="{}"></video>'\
50 .format(self.file.url)
64 .format(self.file.url)
51
65
52
66
53 class AudioViewer(AbstractViewer):
67 class AudioViewer(AbstractViewer):
54 @staticmethod
68 @staticmethod
55 def supports(file_type):
69 def supports(file_type):
56 return file_type in FILE_TYPES_AUDIO
70 return file_type in FILE_TYPES_AUDIO
57
71
58 def get_format_view(self):
72 def get_format_view(self):
59 return '<audio controls src="{}"></audio>'.format(self.file.url)
73 return '<audio controls src="{}"></audio>'.format(self.file.url)
60
74
61
75
62 class SvgViewer(AbstractViewer):
76 class SvgViewer(AbstractViewer):
63 @staticmethod
77 @staticmethod
64 def supports(file_type):
78 def supports(file_type):
65 return file_type == FILE_TYPE_SVG
79 return file_type == FILE_TYPE_SVG
66
80
67 def get_format_view(self):
81 def get_format_view(self):
68 return '<a class="thumb" href="{}">'\
82 return '<a class="thumb" href="{}">'\
69 '<img class="post-image-preview" width="200" height="150" src="{}" />'\
83 '<img class="post-image-preview" width="200" height="150" src="{}" />'\
70 '</a>'.format(self.file.url, self.file.url)
84 '</a>'.format(self.file.url, self.file.url)
@@ -1,425 +1,411 b''
1 from datetime import datetime, timedelta, date
2 from datetime import time as dtime
3 import logging
1 import logging
4 import re
2 import re
5 import uuid
3 import uuid
6
4
7 from django.core.exceptions import ObjectDoesNotExist
5 from django.core.exceptions import ObjectDoesNotExist
8 from django.core.urlresolvers import reverse
6 from django.core.urlresolvers import reverse
9 from django.db import models
7 from django.db import models
10 from django.db.models import TextField, QuerySet
8 from django.db.models import TextField, QuerySet
11 from django.template.loader import render_to_string
9 from django.template.loader import render_to_string
12 from django.utils import timezone
10 from django.utils import timezone
13
11
12 from boards import settings
14 from boards.abstracts.tripcode import Tripcode
13 from boards.abstracts.tripcode import Tripcode
15 from boards.mdx_neboard import Parser
14 from boards.mdx_neboard import Parser
16 from boards.models import KeyPair, GlobalId
15 from boards.models import PostImage, Attachment, KeyPair, GlobalId
17 from boards import settings
18 from boards.models import PostImage, Attachment
19 from boards.models.base import Viewable
16 from boards.models.base import Viewable
20 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
17 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
21 from boards.models.post.manager import PostManager
18 from boards.models.post.manager import PostManager
22 from boards.models.user import Notification
19 from boards.models.user import Notification
23
20
24 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
25 WS_NOTIFICATION_TYPE = 'notification_type'
26
27 WS_CHANNEL_THREAD = "thread:"
28
29 APP_LABEL_BOARDS = 'boards'
21 APP_LABEL_BOARDS = 'boards'
30
22
31 BAN_REASON_AUTO = 'Auto'
23 BAN_REASON_AUTO = 'Auto'
32
24
33 IMAGE_THUMB_SIZE = (200, 150)
25 IMAGE_THUMB_SIZE = (200, 150)
34
26
35 TITLE_MAX_LENGTH = 200
27 TITLE_MAX_LENGTH = 200
36
28
37 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
29 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
38 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
30 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
39 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
31 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
40 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
32 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
41
33
42 PARAMETER_TRUNCATED = 'truncated'
34 PARAMETER_TRUNCATED = 'truncated'
43 PARAMETER_TAG = 'tag'
35 PARAMETER_TAG = 'tag'
44 PARAMETER_OFFSET = 'offset'
36 PARAMETER_OFFSET = 'offset'
45 PARAMETER_DIFF_TYPE = 'type'
37 PARAMETER_DIFF_TYPE = 'type'
46 PARAMETER_CSS_CLASS = 'css_class'
38 PARAMETER_CSS_CLASS = 'css_class'
47 PARAMETER_THREAD = 'thread'
39 PARAMETER_THREAD = 'thread'
48 PARAMETER_IS_OPENING = 'is_opening'
40 PARAMETER_IS_OPENING = 'is_opening'
49 PARAMETER_MODERATOR = 'moderator'
41 PARAMETER_MODERATOR = 'moderator'
50 PARAMETER_POST = 'post'
42 PARAMETER_POST = 'post'
51 PARAMETER_OP_ID = 'opening_post_id'
43 PARAMETER_OP_ID = 'opening_post_id'
52 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
44 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
53 PARAMETER_REPLY_LINK = 'reply_link'
45 PARAMETER_REPLY_LINK = 'reply_link'
54 PARAMETER_NEED_OP_DATA = 'need_op_data'
46 PARAMETER_NEED_OP_DATA = 'need_op_data'
55
47
56 POST_VIEW_PARAMS = (
48 POST_VIEW_PARAMS = (
57 'need_op_data',
49 'need_op_data',
58 'reply_link',
50 'reply_link',
59 'moderator',
51 'moderator',
60 'need_open_link',
52 'need_open_link',
61 'truncated',
53 'truncated',
62 'mode_tree',
54 'mode_tree',
63 )
55 )
64
56
65
57
66 class Post(models.Model, Viewable):
58 class Post(models.Model, Viewable):
67 """A post is a message."""
59 """A post is a message."""
68
60
69 objects = PostManager()
61 objects = PostManager()
70
62
71 class Meta:
63 class Meta:
72 app_label = APP_LABEL_BOARDS
64 app_label = APP_LABEL_BOARDS
73 ordering = ('id',)
65 ordering = ('id',)
74
66
75 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
67 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
76 pub_time = models.DateTimeField()
68 pub_time = models.DateTimeField()
77 text = TextField(blank=True, null=True)
69 text = TextField(blank=True, null=True)
78 _text_rendered = TextField(blank=True, null=True, editable=False)
70 _text_rendered = TextField(blank=True, null=True, editable=False)
79
71
80 images = models.ManyToManyField(PostImage, null=True, blank=True,
72 images = models.ManyToManyField(PostImage, null=True, blank=True,
81 related_name='post_images', db_index=True)
73 related_name='post_images', db_index=True)
82 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
74 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
83 related_name='attachment_posts')
75 related_name='attachment_posts')
84
76
85 poster_ip = models.GenericIPAddressField()
77 poster_ip = models.GenericIPAddressField()
86
78
87 # TODO This field can be removed cause UID is used for update now
79 # TODO This field can be removed cause UID is used for update now
88 last_edit_time = models.DateTimeField()
80 last_edit_time = models.DateTimeField()
89
81
90 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
82 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
91 null=True,
83 null=True,
92 blank=True, related_name='refposts',
84 blank=True, related_name='refposts',
93 db_index=True)
85 db_index=True)
94 refmap = models.TextField(null=True, blank=True)
86 refmap = models.TextField(null=True, blank=True)
95 threads = models.ManyToManyField('Thread', db_index=True)
87 threads = models.ManyToManyField('Thread', db_index=True,
88 related_name='multi_replies')
96 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
89 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
97
90
98 url = models.TextField()
91 url = models.TextField()
99 uid = models.TextField(db_index=True)
92 uid = models.TextField(db_index=True)
100
93
101 # Global ID with author key. If the message was downloaded from another
94 # Global ID with author key. If the message was downloaded from another
102 # server, this indicates the server.
95 # server, this indicates the server.
103 global_id = models.OneToOneField('GlobalId', null=True, blank=True)
96 global_id = models.OneToOneField('GlobalId', null=True, blank=True)
104
97
105 tripcode = models.CharField(max_length=50, null=True)
98 tripcode = models.CharField(max_length=50, null=True)
99 opening = models.BooleanField()
106
100
107 def __str__(self):
101 def __str__(self):
108 return 'P#{}/{}'.format(self.id, self.title)
102 return 'P#{}/{}'.format(self.id, self.get_title())
109
103
110 def get_referenced_posts(self):
104 def get_referenced_posts(self):
111 threads = self.get_threads().all()
105 threads = self.get_threads().all()
112 return self.referenced_posts.filter(threads__in=threads) \
106 return self.referenced_posts.filter(threads__in=threads)\
113 .order_by('pub_time').distinct().all()
107 .order_by('pub_time').distinct().all()
114
108
115 def get_title(self) -> str:
109 def get_title(self) -> str:
116 """
110 return self.title
117 Gets original post title or part of its text.
118 """
119
111
120 title = self.title
112 def get_title_or_text(self):
113 title = self.get_title()
121 if not title:
114 if not title:
122 title = self.get_text()
115 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
123
116
124 return title
117 return title
125
118
126 def build_refmap(self) -> None:
119 def build_refmap(self) -> None:
127 """
120 """
128 Builds a replies map string from replies list. This is a cache to stop
121 Builds a replies map string from replies list. This is a cache to stop
129 the server from recalculating the map on every post show.
122 the server from recalculating the map on every post show.
130 """
123 """
131
124
132 post_urls = [refpost.get_link_view()
125 post_urls = [refpost.get_link_view()
133 for refpost in self.referenced_posts.all()]
126 for refpost in self.referenced_posts.all()]
134
127
135 self.refmap = ', '.join(post_urls)
128 self.refmap = ', '.join(post_urls)
136
129
137 def is_referenced(self) -> bool:
130 def is_referenced(self) -> bool:
138 return self.refmap and len(self.refmap) > 0
131 return self.refmap and len(self.refmap) > 0
139
132
140 def is_opening(self) -> bool:
133 def is_opening(self) -> bool:
141 """
134 """
142 Checks if this is an opening post or just a reply.
135 Checks if this is an opening post or just a reply.
143 """
136 """
144
137
145 return self.get_thread().get_opening_post_id() == self.id
138 return self.opening
146
139
147 def get_absolute_url(self):
140 def get_absolute_url(self):
148 if self.url:
141 if self.url:
149 return self.url
142 return self.url
150 else:
143 else:
151 opening_id = self.get_thread().get_opening_post_id()
144 opening_id = self.get_thread().get_opening_post_id()
152 post_url = reverse('thread', kwargs={'post_id': opening_id})
145 post_url = reverse('thread', kwargs={'post_id': opening_id})
153 if self.id != opening_id:
146 if self.id != opening_id:
154 post_url += '#' + str(self.id)
147 post_url += '#' + str(self.id)
155 return post_url
148 return post_url
156
149
157 def get_thread(self):
150 def get_thread(self):
158 return self.thread
151 return self.thread
159
152
160 def get_threads(self) -> QuerySet:
153 def get_threads(self) -> QuerySet:
161 """
154 """
162 Gets post's thread.
155 Gets post's thread.
163 """
156 """
164
157
165 return self.threads
158 return self.threads
166
159
167 def get_view(self, *args, **kwargs) -> str:
160 def get_view(self, *args, **kwargs) -> str:
168 """
161 """
169 Renders post's HTML view. Some of the post params can be passed over
162 Renders post's HTML view. Some of the post params can be passed over
170 kwargs for the means of caching (if we view the thread, some params
163 kwargs for the means of caching (if we view the thread, some params
171 are same for every post and don't need to be computed over and over.
164 are same for every post and don't need to be computed over and over.
172 """
165 """
173
166
174 thread = self.get_thread()
167 thread = self.get_thread()
175 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
176
177 if is_opening:
178 opening_post_id = self.id
179 else:
180 opening_post_id = thread.get_opening_post_id()
181
168
182 css_class = 'post'
169 css_class = 'post'
183 if thread.archived:
170 if thread.archived:
184 css_class += ' archive_post'
171 css_class += ' archive_post'
185 elif not thread.can_bump():
172 elif not thread.can_bump():
186 css_class += ' dead_post'
173 css_class += ' dead_post'
187
174
188 params = dict()
175 params = dict()
189 for param in POST_VIEW_PARAMS:
176 for param in POST_VIEW_PARAMS:
190 if param in kwargs:
177 if param in kwargs:
191 params[param] = kwargs[param]
178 params[param] = kwargs[param]
192
179
193 params.update({
180 params.update({
194 PARAMETER_POST: self,
181 PARAMETER_POST: self,
195 PARAMETER_IS_OPENING: is_opening,
182 PARAMETER_IS_OPENING: self.is_opening(),
196 PARAMETER_THREAD: thread,
183 PARAMETER_THREAD: thread,
197 PARAMETER_CSS_CLASS: css_class,
184 PARAMETER_CSS_CLASS: css_class,
198 PARAMETER_OP_ID: opening_post_id,
199 })
185 })
200
186
201 return render_to_string('boards/post.html', params)
187 return render_to_string('boards/post.html', params)
202
188
203 def get_search_view(self, *args, **kwargs):
189 def get_search_view(self, *args, **kwargs):
204 return self.get_view(need_op_data=True, *args, **kwargs)
190 return self.get_view(need_op_data=True, *args, **kwargs)
205
191
206 def get_first_image(self) -> PostImage:
192 def get_first_image(self) -> PostImage:
207 return self.images.earliest('id')
193 return self.images.earliest('id')
208
194
209 def delete(self, using=None):
195 def delete(self, using=None):
210 """
196 """
211 Deletes all post images and the post itself.
197 Deletes all post images and the post itself.
212 """
198 """
213
199
214 for image in self.images.all():
200 for image in self.images.all():
215 image_refs_count = image.post_images.count()
201 image_refs_count = image.post_images.count()
216 if image_refs_count == 1:
202 if image_refs_count == 1:
217 image.delete()
203 image.delete()
218
204
219 for attachment in self.attachments.all():
205 for attachment in self.attachments.all():
220 attachment_refs_count = attachment.attachment_posts.count()
206 attachment_refs_count = attachment.attachment_posts.count()
221 if attachment_refs_count == 1:
207 if attachment_refs_count == 1:
222 attachment.delete()
208 attachment.delete()
223
209
224 if self.global_id:
210 if self.global_id:
225 self.global_id.delete()
211 self.global_id.delete()
226
212
227 thread = self.get_thread()
213 thread = self.get_thread()
228 thread.last_edit_time = timezone.now()
214 thread.last_edit_time = timezone.now()
229 thread.save()
215 thread.save()
230
216
231 super(Post, self).delete(using)
217 super(Post, self).delete(using)
232
218
233 logging.getLogger('boards.post.delete').info(
219 logging.getLogger('boards.post.delete').info(
234 'Deleted post {}'.format(self))
220 'Deleted post {}'.format(self))
235
221
236 def set_global_id(self, key_pair=None):
222 def set_global_id(self, key_pair=None):
237 """
223 """
238 Sets global id based on the given key pair. If no key pair is given,
224 Sets global id based on the given key pair. If no key pair is given,
239 default one is used.
225 default one is used.
240 """
226 """
241
227
242 if key_pair:
228 if key_pair:
243 key = key_pair
229 key = key_pair
244 else:
230 else:
245 try:
231 try:
246 key = KeyPair.objects.get(primary=True)
232 key = KeyPair.objects.get(primary=True)
247 except KeyPair.DoesNotExist:
233 except KeyPair.DoesNotExist:
248 # Do not update the global id because there is no key defined
234 # Do not update the global id because there is no key defined
249 return
235 return
250 global_id = GlobalId(key_type=key.key_type,
236 global_id = GlobalId(key_type=key.key_type,
251 key=key.public_key,
237 key=key.public_key,
252 local_id=self.id)
238 local_id=self.id)
253 global_id.save()
239 global_id.save()
254
240
255 self.global_id = global_id
241 self.global_id = global_id
256
242
257 self.save(update_fields=['global_id'])
243 self.save(update_fields=['global_id'])
258
244
259 def get_pub_time_str(self):
245 def get_pub_time_str(self):
260 return str(self.pub_time)
246 return str(self.pub_time)
261
247
262 def get_replied_ids(self):
248 def get_replied_ids(self):
263 """
249 """
264 Gets ID list of the posts that this post replies.
250 Gets ID list of the posts that this post replies.
265 """
251 """
266
252
267 raw_text = self.get_raw_text()
253 raw_text = self.get_raw_text()
268
254
269 local_replied = REGEX_REPLY.findall(raw_text)
255 local_replied = REGEX_REPLY.findall(raw_text)
270 global_replied = []
256 global_replied = []
271 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
257 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
272 key_type = match[0]
258 key_type = match[0]
273 key = match[1]
259 key = match[1]
274 local_id = match[2]
260 local_id = match[2]
275
261
276 try:
262 try:
277 global_id = GlobalId.objects.get(key_type=key_type,
263 global_id = GlobalId.objects.get(key_type=key_type,
278 key=key, local_id=local_id)
264 key=key, local_id=local_id)
279 for post in Post.objects.filter(global_id=global_id).only('id'):
265 for post in Post.objects.filter(global_id=global_id).only('id'):
280 global_replied.append(post.id)
266 global_replied.append(post.id)
281 except GlobalId.DoesNotExist:
267 except GlobalId.DoesNotExist:
282 pass
268 pass
283 return local_replied + global_replied
269 return local_replied + global_replied
284
270
285 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
271 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
286 include_last_update=False) -> str:
272 include_last_update=False) -> str:
287 """
273 """
288 Gets post HTML or JSON data that can be rendered on a page or used by
274 Gets post HTML or JSON data that can be rendered on a page or used by
289 API.
275 API.
290 """
276 """
291
277
292 return get_exporter(format_type).export(self, request,
278 return get_exporter(format_type).export(self, request,
293 include_last_update)
279 include_last_update)
294
280
295 def notify_clients(self, recursive=True):
281 def notify_clients(self, recursive=True):
296 """
282 """
297 Sends post HTML data to the thread web socket.
283 Sends post HTML data to the thread web socket.
298 """
284 """
299
285
300 if not settings.get_bool('External', 'WebsocketsEnabled'):
286 if not settings.get_bool('External', 'WebsocketsEnabled'):
301 return
287 return
302
288
303 thread_ids = list()
289 thread_ids = list()
304 for thread in self.get_threads().all():
290 for thread in self.get_threads().all():
305 thread_ids.append(thread.id)
291 thread_ids.append(thread.id)
306
292
307 thread.notify_clients()
293 thread.notify_clients()
308
294
309 if recursive:
295 if recursive:
310 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
296 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
311 post_id = reply_number.group(1)
297 post_id = reply_number.group(1)
312
298
313 try:
299 try:
314 ref_post = Post.objects.get(id=post_id)
300 ref_post = Post.objects.get(id=post_id)
315
301
316 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
302 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
317 # If post is in this thread, its thread was already notified.
303 # If post is in this thread, its thread was already notified.
318 # Otherwise, notify its thread separately.
304 # Otherwise, notify its thread separately.
319 ref_post.notify_clients(recursive=False)
305 ref_post.notify_clients(recursive=False)
320 except ObjectDoesNotExist:
306 except ObjectDoesNotExist:
321 pass
307 pass
322
308
323 def build_url(self):
309 def build_url(self):
324 self.url = self.get_absolute_url()
310 self.url = self.get_absolute_url()
325 self.save(update_fields=['url'])
311 self.save(update_fields=['url'])
326
312
327 def save(self, force_insert=False, force_update=False, using=None,
313 def save(self, force_insert=False, force_update=False, using=None,
328 update_fields=None):
314 update_fields=None):
329 self._text_rendered = Parser().parse(self.get_raw_text())
315 self._text_rendered = Parser().parse(self.get_raw_text())
330
316
331 self.uid = str(uuid.uuid4())
317 self.uid = str(uuid.uuid4())
332 if update_fields is not None and 'uid' not in update_fields:
318 if update_fields is not None and 'uid' not in update_fields:
333 update_fields += ['uid']
319 update_fields += ['uid']
334
320
335 if self.id:
321 if self.id:
336 for thread in self.get_threads().all():
322 for thread in self.get_threads().all():
337 thread.last_edit_time = self.last_edit_time
323 thread.last_edit_time = self.last_edit_time
338
324
339 thread.save(update_fields=['last_edit_time'])
325 thread.save(update_fields=['last_edit_time', 'bumpable'])
340
326
341 super().save(force_insert, force_update, using, update_fields)
327 super().save(force_insert, force_update, using, update_fields)
342
328
343 def get_text(self) -> str:
329 def get_text(self) -> str:
344 return self._text_rendered
330 return self._text_rendered
345
331
346 def get_raw_text(self) -> str:
332 def get_raw_text(self) -> str:
347 return self.text
333 return self.text
348
334
349 def get_sync_text(self) -> str:
335 def get_sync_text(self) -> str:
350 """
336 """
351 Returns text applicable for sync. It has absolute post reflinks.
337 Returns text applicable for sync. It has absolute post reflinks.
352 """
338 """
353
339
354 replacements = dict()
340 replacements = dict()
355 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
341 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
356 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
342 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
357 replacements[post_id] = absolute_post_id
343 replacements[post_id] = absolute_post_id
358
344
359 text = self.get_raw_text()
345 text = self.get_raw_text()
360 for key in replacements:
346 for key in replacements:
361 text = text.replace('[post]{}[/post]'.format(key),
347 text = text.replace('[post]{}[/post]'.format(key),
362 '[post]{}[/post]'.format(replacements[key]))
348 '[post]{}[/post]'.format(replacements[key]))
363
349
364 return text
350 return text
365
351
366 def get_absolute_id(self) -> str:
352 def get_absolute_id(self) -> str:
367 """
353 """
368 If the post has many threads, shows its main thread OP id in the post
354 If the post has many threads, shows its main thread OP id in the post
369 ID.
355 ID.
370 """
356 """
371
357
372 if self.get_threads().count() > 1:
358 if self.get_threads().count() > 1:
373 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
359 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
374 else:
360 else:
375 return str(self.id)
361 return str(self.id)
376
362
377 def connect_notifications(self):
363 def connect_notifications(self):
378 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
364 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
379 user_name = reply_number.group(1).lower()
365 user_name = reply_number.group(1).lower()
380 Notification.objects.get_or_create(name=user_name, post=self)
366 Notification.objects.get_or_create(name=user_name, post=self)
381
367
382 def connect_replies(self):
368 def connect_replies(self):
383 """
369 """
384 Connects replies to a post to show them as a reflink map
370 Connects replies to a post to show them as a reflink map
385 """
371 """
386
372
387 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
373 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
388 post_id = reply_number.group(1)
374 post_id = reply_number.group(1)
389
375
390 try:
376 try:
391 referenced_post = Post.objects.get(id=post_id)
377 referenced_post = Post.objects.get(id=post_id)
392
378
393 referenced_post.referenced_posts.add(self)
379 referenced_post.referenced_posts.add(self)
394 referenced_post.last_edit_time = self.pub_time
380 referenced_post.last_edit_time = self.pub_time
395 referenced_post.build_refmap()
381 referenced_post.build_refmap()
396 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
382 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
397 except ObjectDoesNotExist:
383 except ObjectDoesNotExist:
398 pass
384 pass
399
385
400 def connect_threads(self, opening_posts):
386 def connect_threads(self, opening_posts):
401 for opening_post in opening_posts:
387 for opening_post in opening_posts:
402 threads = opening_post.get_threads().all()
388 threads = opening_post.get_threads().all()
403 for thread in threads:
389 for thread in threads:
404 if thread.can_bump():
390 if thread.can_bump():
405 thread.update_bump_status()
391 thread.update_bump_status()
406
392
407 thread.last_edit_time = self.last_edit_time
393 thread.last_edit_time = self.last_edit_time
408 thread.save(update_fields=['last_edit_time', 'bumpable'])
394 thread.save(update_fields=['last_edit_time', 'bumpable'])
409 self.threads.add(opening_post.get_thread())
395 self.threads.add(opening_post.get_thread())
410
396
411 def get_tripcode(self):
397 def get_tripcode(self):
412 if self.tripcode:
398 if self.tripcode:
413 return Tripcode(self.tripcode)
399 return Tripcode(self.tripcode)
414
400
415 def get_link_view(self):
401 def get_link_view(self):
416 """
402 """
417 Gets view of a reflink to the post.
403 Gets view of a reflink to the post.
418 """
404 """
419
405
420 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
406 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
421 self.id)
407 self.id)
422 if self.is_opening():
408 if self.is_opening():
423 result = '<b>{}</b>'.format(result)
409 result = '<b>{}</b>'.format(result)
424
410
425 return result
411 return result
@@ -1,140 +1,145 b''
1 from datetime import datetime, timedelta, date
1 from datetime import datetime, timedelta, date
2 from datetime import time as dtime
2 from datetime import time as dtime
3 import logging
3 import logging
4 from django.db import models, transaction
4 from django.db import models, transaction
5 from django.utils import timezone
5 from django.utils import timezone
6 from boards import utils
6 from boards import utils
7 from boards.mdx_neboard import Parser
7 from boards.mdx_neboard import Parser
8 from boards.models import PostImage, Attachment
8 from boards.models import PostImage, Attachment
9 from boards.models.user import Ban
9 import boards.models
10 import boards.models
10
11
11 __author__ = 'vurdalak'
12 __author__ = 'vurdalak'
12
13
13
14
14 NO_IP = '0.0.0.0'
15 NO_IP = '0.0.0.0'
15 POSTS_PER_DAY_RANGE = 7
16 POSTS_PER_DAY_RANGE = 7
16
17
17 IMAGE_TYPES = (
18 IMAGE_TYPES = (
18 'jpeg',
19 'jpeg',
19 'jpg',
20 'jpg',
20 'png',
21 'png',
21 'bmp',
22 'bmp',
22 'gif',
23 'gif',
23 )
24 )
24
25
25
26
26 class PostManager(models.Manager):
27 class PostManager(models.Manager):
27 @transaction.atomic
28 @transaction.atomic
28 def create_post(self, title: str, text: str, file=None, thread=None,
29 def create_post(self, title: str, text: str, file=None, thread=None,
29 ip=NO_IP, tags: list=None, opening_posts: list=None, tripcode=None):
30 ip=NO_IP, tags: list=None, opening_posts: list=None, tripcode=None):
30 """
31 """
31 Creates new post
32 Creates new post
32 """
33 """
33
34
34 is_banned = boards.models.Ban.objects.filter(ip=ip).exists()
35 is_banned = Ban.objects.filter(ip=ip).exists()
35
36
36 # TODO Raise specific exception and catch it in the views
37 # TODO Raise specific exception and catch it in the views
37 if is_banned:
38 if is_banned:
38 raise Exception("This user is banned")
39 raise Exception("This user is banned")
39
40
40 if not tags:
41 if not tags:
41 tags = []
42 tags = []
42 if not opening_posts:
43 if not opening_posts:
43 opening_posts = []
44 opening_posts = []
44
45
45 posting_time = timezone.now()
46 posting_time = timezone.now()
46 new_thread = False
47 new_thread = False
47 if not thread:
48 if not thread:
48 thread = boards.models.thread.Thread.objects.create(
49 thread = boards.models.thread.Thread.objects.create(
49 bump_time=posting_time, last_edit_time=posting_time)
50 bump_time=posting_time, last_edit_time=posting_time)
50 list(map(thread.tags.add, tags))
51 list(map(thread.tags.add, tags))
51 boards.models.thread.Thread.objects.process_oldest_threads()
52 boards.models.thread.Thread.objects.process_oldest_threads()
52 new_thread = True
53 new_thread = True
53
54
54 pre_text = Parser().preparse(text)
55 pre_text = Parser().preparse(text)
55
56
56 post = self.create(title=title,
57 post = self.create(title=title,
57 text=pre_text,
58 text=pre_text,
58 pub_time=posting_time,
59 pub_time=posting_time,
59 poster_ip=ip,
60 poster_ip=ip,
60 thread=thread,
61 thread=thread,
61 last_edit_time=posting_time,
62 last_edit_time=posting_time,
62 tripcode=tripcode)
63 tripcode=tripcode,
64 opening=new_thread)
63 post.threads.add(thread)
65 post.threads.add(thread)
64
66
65 logger = logging.getLogger('boards.post.create')
67 logger = logging.getLogger('boards.post.create')
66
68
67 logger.info('Created post {} by {}'.format(post, post.poster_ip))
69 logger.info('Created post {} by {}'.format(post, post.poster_ip))
68
70
69 # TODO Move this to other place
71 # TODO Move this to other place
70 if file:
72 if file:
71 file_type = file.name.split('.')[-1].lower()
73 file_type = file.name.split('.')[-1].lower()
72 if file_type in IMAGE_TYPES:
74 if file_type in IMAGE_TYPES:
73 post.images.add(PostImage.objects.create_with_hash(file))
75 post.images.add(PostImage.objects.create_with_hash(file))
74 else:
76 else:
75 post.attachments.add(Attachment.objects.create_with_hash(file))
77 post.attachments.add(Attachment.objects.create_with_hash(file))
76
78
77 post.build_url()
79 post.build_url()
78 post.connect_replies()
80 post.connect_replies()
79 post.connect_threads(opening_posts)
81 post.connect_threads(opening_posts)
80 post.connect_notifications()
82 post.connect_notifications()
81 post.set_global_id()
83 post.set_global_id()
82
84
83 # Thread needs to be bumped only when the post is already created
85 # Thread needs to be bumped only when the post is already created
84 if not new_thread:
86 if not new_thread:
85 thread.last_edit_time = posting_time
87 thread.last_edit_time = posting_time
86 thread.bump()
88 thread.bump()
87 thread.save()
89 thread.save()
88
90
89 return post
91 return post
90
92
91 def delete_posts_by_ip(self, ip):
93 def delete_posts_by_ip(self, ip):
92 """
94 """
93 Deletes all posts of the author with same IP
95 Deletes all posts of the author with same IP
94 """
96 """
95
97
96 posts = self.filter(poster_ip=ip)
98 posts = self.filter(poster_ip=ip)
97 for post in posts:
99 for post in posts:
98 post.delete()
100 post.delete()
99
101
100 @utils.cached_result()
102 @utils.cached_result()
101 def get_posts_per_day(self) -> float:
103 def get_posts_per_day(self) -> float:
102 """
104 """
103 Gets average count of posts per day for the last 7 days
105 Gets average count of posts per day for the last 7 days
104 """
106 """
105
107
106 day_end = date.today()
108 day_end = date.today()
107 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
109 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
108
110
109 day_time_start = timezone.make_aware(datetime.combine(
111 day_time_start = timezone.make_aware(datetime.combine(
110 day_start, dtime()), timezone.get_current_timezone())
112 day_start, dtime()), timezone.get_current_timezone())
111 day_time_end = timezone.make_aware(datetime.combine(
113 day_time_end = timezone.make_aware(datetime.combine(
112 day_end, dtime()), timezone.get_current_timezone())
114 day_end, dtime()), timezone.get_current_timezone())
113
115
114 posts_per_period = float(self.filter(
116 posts_per_period = float(self.filter(
115 pub_time__lte=day_time_end,
117 pub_time__lte=day_time_end,
116 pub_time__gte=day_time_start).count())
118 pub_time__gte=day_time_start).count())
117
119
118 ppd = posts_per_period / POSTS_PER_DAY_RANGE
120 ppd = posts_per_period / POSTS_PER_DAY_RANGE
119
121
120 return ppd
122 return ppd
121
123
122 @transaction.atomic
124 @transaction.atomic
123 def import_post(self, title: str, text: str, pub_time: str, global_id,
125 def import_post(self, title: str, text: str, pub_time: str, global_id,
124 opening_post=None, tags=list()):
126 opening_post=None, tags=list()):
125 if opening_post is None:
127 is_opening = opening_post is None
128 if is_opening:
126 thread = boards.models.thread.Thread.objects.create(
129 thread = boards.models.thread.Thread.objects.create(
127 bump_time=pub_time, last_edit_time=pub_time)
130 bump_time=pub_time, last_edit_time=pub_time)
128 list(map(thread.tags.add, tags))
131 list(map(thread.tags.add, tags))
129 else:
132 else:
130 thread = opening_post.get_thread()
133 thread = opening_post.get_thread()
131
134
132 post = self.create(title=title, text=text,
135 post = self.create(title=title, text=text,
133 pub_time=pub_time,
136 pub_time=pub_time,
134 poster_ip=NO_IP,
137 poster_ip=NO_IP,
135 last_edit_time=pub_time,
138 last_edit_time=pub_time,
136 thread_id=thread.id, global_id=global_id)
139 thread_id=thread.id,
140 global_id=global_id,
141 opening=is_opening)
137
142
138 post.build_url()
143 post.build_url()
139 post.connect_replies()
144 post.connect_replies()
140 post.connect_notifications()
145 post.connect_notifications()
@@ -1,109 +1,134 b''
1 import hashlib
1 from django.template.loader import render_to_string
2 from django.template.loader import render_to_string
2 from django.db import models
3 from django.db import models
3 from django.db.models import Count
4 from django.db.models import Count
4 from django.core.urlresolvers import reverse
5 from django.core.urlresolvers import reverse
5
6
6 from boards.models.base import Viewable
7 from boards.models.base import Viewable
7 from boards.utils import cached_result
8 from boards.utils import cached_result
8 import boards
9 import boards
9
10
10 __author__ = 'neko259'
11 __author__ = 'neko259'
11
12
12
13
13 RELATED_TAGS_COUNT = 5
14 RELATED_TAGS_COUNT = 5
14
15
15
16
16 class TagManager(models.Manager):
17 class TagManager(models.Manager):
17
18
18 def get_not_empty_tags(self):
19 def get_not_empty_tags(self):
19 """
20 """
20 Gets tags that have non-archived threads.
21 Gets tags that have non-archived threads.
21 """
22 """
22
23
23 return self.annotate(num_threads=Count('thread_tags')).filter(num_threads__gt=0)\
24 return self.annotate(num_threads=Count('thread_tags')).filter(num_threads__gt=0)\
24 .order_by('-required', 'name')
25 .order_by('-required', 'name')
25
26
26 def get_tag_url_list(self, tags: list) -> str:
27 def get_tag_url_list(self, tags: list) -> str:
27 """
28 """
28 Gets a comma-separated list of tag links.
29 Gets a comma-separated list of tag links.
29 """
30 """
30
31
31 return ', '.join([tag.get_view() for tag in tags])
32 return ', '.join([tag.get_view() for tag in tags])
32
33
33
34
34 class Tag(models.Model, Viewable):
35 class Tag(models.Model, Viewable):
35 """
36 """
36 A tag is a text node assigned to the thread. The tag serves as a board
37 A tag is a text node assigned to the thread. The tag serves as a board
37 section. There can be multiple tags for each thread
38 section. There can be multiple tags for each thread
38 """
39 """
39
40
40 objects = TagManager()
41 objects = TagManager()
41
42
42 class Meta:
43 class Meta:
43 app_label = 'boards'
44 app_label = 'boards'
44 ordering = ('name',)
45 ordering = ('name',)
45
46
46 name = models.CharField(max_length=100, db_index=True, unique=True)
47 name = models.CharField(max_length=100, db_index=True, unique=True)
47 required = models.BooleanField(default=False, db_index=True)
48 required = models.BooleanField(default=False, db_index=True)
48 description = models.TextField(blank=True)
49 description = models.TextField(blank=True)
49
50
51 parent = models.ForeignKey('Tag', null=True, related_name='children')
52
50 def __str__(self):
53 def __str__(self):
51 return self.name
54 return self.name
52
55
53 def is_empty(self) -> bool:
56 def is_empty(self) -> bool:
54 """
57 """
55 Checks if the tag has some threads.
58 Checks if the tag has some threads.
56 """
59 """
57
60
58 return self.get_thread_count() == 0
61 return self.get_thread_count() == 0
59
62
60 def get_thread_count(self, archived=None) -> int:
63 def get_thread_count(self, archived=None) -> int:
61 threads = self.get_threads()
64 threads = self.get_threads()
62 if archived is not None:
65 if archived is not None:
63 threads = threads.filter(archived=archived)
66 threads = threads.filter(archived=archived)
64 return threads.count()
67 return threads.count()
65
68
66 def get_active_thread_count(self) -> int:
69 def get_active_thread_count(self) -> int:
67 return self.get_thread_count(archived=False)
70 return self.get_thread_count(archived=False)
68
71
69 def get_absolute_url(self):
72 def get_absolute_url(self):
70 return reverse('tag', kwargs={'tag_name': self.name})
73 return reverse('tag', kwargs={'tag_name': self.name})
71
74
72 def get_threads(self):
75 def get_threads(self):
73 return self.thread_tags.order_by('-bump_time')
76 return self.thread_tags.order_by('-bump_time')
74
77
75 def is_required(self):
78 def is_required(self):
76 return self.required
79 return self.required
77
80
78 def get_view(self):
81 def get_view(self):
79 link = '<a class="tag" href="{}">{}</a>'.format(
82 link = '<a class="tag" href="{}">{}</a>'.format(
80 self.get_absolute_url(), self.name)
83 self.get_absolute_url(), self.name)
81 if self.is_required():
84 if self.is_required():
82 link = '<b>{}</b>'.format(link)
85 link = '<b>{}</b>'.format(link)
83 return link
86 return link
84
87
85 def get_search_view(self, *args, **kwargs):
88 def get_search_view(self, *args, **kwargs):
86 return render_to_string('boards/tag.html', {
89 return render_to_string('boards/tag.html', {
87 'tag': self,
90 'tag': self,
88 })
91 })
89
92
90 @cached_result()
93 @cached_result()
91 def get_post_count(self):
94 def get_post_count(self):
92 return self.get_threads().aggregate(num_posts=Count('post'))['num_posts']
95 return self.get_threads().aggregate(num_posts=Count('multi_replies'))['num_posts']
93
96
94 def get_description(self):
97 def get_description(self):
95 return self.description
98 return self.description
96
99
97 def get_random_image_post(self, archived=False):
100 def get_random_image_post(self, archived=False):
98 posts = boards.models.Post.objects.annotate(images_count=Count(
101 posts = boards.models.Post.objects.annotate(images_count=Count(
99 'images')).filter(images_count__gt=0, threads__tags__in=[self])
102 'images')).filter(images_count__gt=0, threads__tags__in=[self])
100 if archived is not None:
103 if archived is not None:
101 posts = posts.filter(thread__archived=archived)
104 posts = posts.filter(thread__archived=archived)
102 return posts.order_by('?').first()
105 return posts.order_by('?').first()
103
106
104 def get_first_letter(self):
107 def get_first_letter(self):
105 return self.name and self.name[0] or ''
108 return self.name and self.name[0] or ''
106
109
107 def get_related_tags(self):
110 def get_related_tags(self):
108 return set(Tag.objects.filter(thread_tags__in=self.get_threads()).exclude(
111 return set(Tag.objects.filter(thread_tags__in=self.get_threads()).exclude(
109 id=self.id).order_by('?')[:RELATED_TAGS_COUNT])
112 id=self.id).order_by('?')[:RELATED_TAGS_COUNT])
113
114 @cached_result()
115 def get_color(self):
116 """
117 Gets color hashed from the tag name.
118 """
119 return hashlib.md5(self.name.encode()).hexdigest()[:6]
120
121 def get_parent(self):
122 return self.parent
123
124 def get_all_parents(self):
125 parents = set()
126 parent = self.get_parent()
127 if parent and parent not in parents:
128 parents.add(parent)
129 parents |= parent.get_all_parents()
130
131 return parents
132
133 def get_children(self):
134 return self.children
@@ -1,230 +1,258 b''
1 import logging
1 import logging
2 from adjacent import Client
2 from adjacent import Client
3
3
4 from django.db.models import Count, Sum, QuerySet
4 from django.db.models import Count, Sum, QuerySet, Q
5 from django.utils import timezone
5 from django.utils import timezone
6 from django.db import models
6 from django.db import models
7
7
8 from boards import settings
8 from boards import settings
9 import boards
9 import boards
10 from boards.utils import cached_result, datetime_to_epoch
10 from boards.utils import cached_result, datetime_to_epoch
11 from boards.models.post import Post
11 from boards.models.post import Post
12 from boards.models.tag import Tag
12 from boards.models.tag import Tag
13
13
14 FAV_THREAD_NO_UPDATES = -1
15
14
16
15 __author__ = 'neko259'
17 __author__ = 'neko259'
16
18
17
19
18 logger = logging.getLogger(__name__)
20 logger = logging.getLogger(__name__)
19
21
20
22
21 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
23 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
22 WS_NOTIFICATION_TYPE = 'notification_type'
24 WS_NOTIFICATION_TYPE = 'notification_type'
23
25
24 WS_CHANNEL_THREAD = "thread:"
26 WS_CHANNEL_THREAD = "thread:"
25
27
26
28
27 class ThreadManager(models.Manager):
29 class ThreadManager(models.Manager):
28 def process_oldest_threads(self):
30 def process_oldest_threads(self):
29 """
31 """
30 Preserves maximum thread count. If there are too many threads,
32 Preserves maximum thread count. If there are too many threads,
31 archive or delete the old ones.
33 archive or delete the old ones.
32 """
34 """
33
35
34 threads = Thread.objects.filter(archived=False).order_by('-bump_time')
36 threads = Thread.objects.filter(archived=False).order_by('-bump_time')
35 thread_count = threads.count()
37 thread_count = threads.count()
36
38
37 max_thread_count = settings.get_int('Messages', 'MaxThreadCount')
39 max_thread_count = settings.get_int('Messages', 'MaxThreadCount')
38 if thread_count > max_thread_count:
40 if thread_count > max_thread_count:
39 num_threads_to_delete = thread_count - max_thread_count
41 num_threads_to_delete = thread_count - max_thread_count
40 old_threads = threads[thread_count - num_threads_to_delete:]
42 old_threads = threads[thread_count - num_threads_to_delete:]
41
43
42 for thread in old_threads:
44 for thread in old_threads:
43 if settings.get_bool('Storage', 'ArchiveThreads'):
45 if settings.get_bool('Storage', 'ArchiveThreads'):
44 self._archive_thread(thread)
46 self._archive_thread(thread)
45 else:
47 else:
46 thread.delete()
48 thread.delete()
47
49
48 logger.info('Processed %d old threads' % num_threads_to_delete)
50 logger.info('Processed %d old threads' % num_threads_to_delete)
49
51
50 def _archive_thread(self, thread):
52 def _archive_thread(self, thread):
51 thread.archived = True
53 thread.archived = True
52 thread.bumpable = False
54 thread.bumpable = False
53 thread.last_edit_time = timezone.now()
55 thread.last_edit_time = timezone.now()
54 thread.update_posts_time()
56 thread.update_posts_time()
55 thread.save(update_fields=['archived', 'last_edit_time', 'bumpable'])
57 thread.save(update_fields=['archived', 'last_edit_time', 'bumpable'])
56
58
59 def get_new_posts(self, datas):
60 query = None
61 # TODO Use classes instead of dicts
62 for data in datas:
63 if data['last_id'] != FAV_THREAD_NO_UPDATES:
64 q = (Q(id=data['op'].get_thread().id)
65 & Q(multi_replies__id__gt=data['last_id']))
66 if query is None:
67 query = q
68 else:
69 query = query | q
70 if query is not None:
71 return self.filter(query).annotate(
72 new_post_count=Count('multi_replies'))
73
74 def get_new_post_count(self, datas):
75 new_posts = self.get_new_posts(datas)
76 return new_posts.aggregate(total_count=Count('multi_replies'))\
77 ['total_count'] if new_posts else 0
78
57
79
58 def get_thread_max_posts():
80 def get_thread_max_posts():
59 return settings.get_int('Messages', 'MaxPostsPerThread')
81 return settings.get_int('Messages', 'MaxPostsPerThread')
60
82
61
83
62 class Thread(models.Model):
84 class Thread(models.Model):
63 objects = ThreadManager()
85 objects = ThreadManager()
64
86
65 class Meta:
87 class Meta:
66 app_label = 'boards'
88 app_label = 'boards'
67
89
68 tags = models.ManyToManyField('Tag', related_name='thread_tags')
90 tags = models.ManyToManyField('Tag', related_name='thread_tags')
69 bump_time = models.DateTimeField(db_index=True)
91 bump_time = models.DateTimeField(db_index=True)
70 last_edit_time = models.DateTimeField()
92 last_edit_time = models.DateTimeField()
71 archived = models.BooleanField(default=False)
93 archived = models.BooleanField(default=False)
72 bumpable = models.BooleanField(default=True)
94 bumpable = models.BooleanField(default=True)
73 max_posts = models.IntegerField(default=get_thread_max_posts)
95 max_posts = models.IntegerField(default=get_thread_max_posts)
74
96
75 def get_tags(self) -> QuerySet:
97 def get_tags(self) -> QuerySet:
76 """
98 """
77 Gets a sorted tag list.
99 Gets a sorted tag list.
78 """
100 """
79
101
80 return self.tags.order_by('name')
102 return self.tags.order_by('name')
81
103
82 def bump(self):
104 def bump(self):
83 """
105 """
84 Bumps (moves to up) thread if possible.
106 Bumps (moves to up) thread if possible.
85 """
107 """
86
108
87 if self.can_bump():
109 if self.can_bump():
88 self.bump_time = self.last_edit_time
110 self.bump_time = self.last_edit_time
89
111
90 self.update_bump_status()
112 self.update_bump_status()
91
113
92 logger.info('Bumped thread %d' % self.id)
114 logger.info('Bumped thread %d' % self.id)
93
115
94 def has_post_limit(self) -> bool:
116 def has_post_limit(self) -> bool:
95 return self.max_posts > 0
117 return self.max_posts > 0
96
118
97 def update_bump_status(self, exclude_posts=None):
119 def update_bump_status(self, exclude_posts=None):
98 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
120 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
99 self.bumpable = False
121 self.bumpable = False
100 self.update_posts_time(exclude_posts=exclude_posts)
122 self.update_posts_time(exclude_posts=exclude_posts)
101
123
102 def _get_cache_key(self):
124 def _get_cache_key(self):
103 return [datetime_to_epoch(self.last_edit_time)]
125 return [datetime_to_epoch(self.last_edit_time)]
104
126
105 @cached_result(key_method=_get_cache_key)
127 @cached_result(key_method=_get_cache_key)
106 def get_reply_count(self) -> int:
128 def get_reply_count(self) -> int:
107 return self.get_replies().count()
129 return self.get_replies().count()
108
130
109 @cached_result(key_method=_get_cache_key)
131 @cached_result(key_method=_get_cache_key)
110 def get_images_count(self) -> int:
132 def get_images_count(self) -> int:
111 return self.get_replies().annotate(images_count=Count(
133 return self.get_replies().annotate(images_count=Count(
112 'images')).aggregate(Sum('images_count'))['images_count__sum']
134 'images')).aggregate(Sum('images_count'))['images_count__sum']
113
135
114 def can_bump(self) -> bool:
136 def can_bump(self) -> bool:
115 """
137 """
116 Checks if the thread can be bumped by replying to it.
138 Checks if the thread can be bumped by replying to it.
117 """
139 """
118
140
119 return self.bumpable and not self.archived
141 return self.bumpable and not self.is_archived()
120
142
121 def get_last_replies(self) -> QuerySet:
143 def get_last_replies(self) -> QuerySet:
122 """
144 """
123 Gets several last replies, not including opening post
145 Gets several last replies, not including opening post
124 """
146 """
125
147
126 last_replies_count = settings.get_int('View', 'LastRepliesCount')
148 last_replies_count = settings.get_int('View', 'LastRepliesCount')
127
149
128 if last_replies_count > 0:
150 if last_replies_count > 0:
129 reply_count = self.get_reply_count()
151 reply_count = self.get_reply_count()
130
152
131 if reply_count > 0:
153 if reply_count > 0:
132 reply_count_to_show = min(last_replies_count,
154 reply_count_to_show = min(last_replies_count,
133 reply_count - 1)
155 reply_count - 1)
134 replies = self.get_replies()
156 replies = self.get_replies()
135 last_replies = replies[reply_count - reply_count_to_show:]
157 last_replies = replies[reply_count - reply_count_to_show:]
136
158
137 return last_replies
159 return last_replies
138
160
139 def get_skipped_replies_count(self) -> int:
161 def get_skipped_replies_count(self) -> int:
140 """
162 """
141 Gets number of posts between opening post and last replies.
163 Gets number of posts between opening post and last replies.
142 """
164 """
143 reply_count = self.get_reply_count()
165 reply_count = self.get_reply_count()
144 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
166 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
145 reply_count - 1)
167 reply_count - 1)
146 return reply_count - last_replies_count - 1
168 return reply_count - last_replies_count - 1
147
169
148 def get_replies(self, view_fields_only=False) -> QuerySet:
170 def get_replies(self, view_fields_only=False) -> QuerySet:
149 """
171 """
150 Gets sorted thread posts
172 Gets sorted thread posts
151 """
173 """
152
174
153 query = Post.objects.filter(threads__in=[self])
175 query = self.multi_replies.order_by('pub_time').prefetch_related(
154 query = query.order_by('pub_time').prefetch_related('images', 'thread', 'threads')
176 'images', 'thread', 'threads', 'attachments')
155 if view_fields_only:
177 if view_fields_only:
156 query = query.defer('poster_ip')
178 query = query.defer('poster_ip')
157 return query.all()
179 return query.all()
158
180
159 def get_top_level_replies(self) -> QuerySet:
181 def get_top_level_replies(self) -> QuerySet:
160 return self.get_replies().exclude(refposts__threads__in=[self])
182 return self.get_replies().exclude(refposts__threads__in=[self])
161
183
162 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
184 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
163 """
185 """
164 Gets replies that have at least one image attached
186 Gets replies that have at least one image attached
165 """
187 """
166
188
167 return self.get_replies(view_fields_only).annotate(images_count=Count(
189 return self.get_replies(view_fields_only).annotate(images_count=Count(
168 'images')).filter(images_count__gt=0)
190 'images')).filter(images_count__gt=0)
169
191
170 def get_opening_post(self, only_id=False) -> Post:
192 def get_opening_post(self, only_id=False) -> Post:
171 """
193 """
172 Gets the first post of the thread
194 Gets the first post of the thread
173 """
195 """
174
196
175 query = self.get_replies().order_by('pub_time')
197 query = self.get_replies().order_by('pub_time')
176 if only_id:
198 if only_id:
177 query = query.only('id')
199 query = query.only('id')
178 opening_post = query.first()
200 opening_post = query.first()
179
201
180 return opening_post
202 return opening_post
181
203
182 @cached_result()
204 @cached_result()
183 def get_opening_post_id(self) -> int:
205 def get_opening_post_id(self) -> int:
184 """
206 """
185 Gets ID of the first thread post.
207 Gets ID of the first thread post.
186 """
208 """
187
209
188 return self.get_opening_post(only_id=True).id
210 return self.get_opening_post(only_id=True).id
189
211
190 def get_pub_time(self):
212 def get_pub_time(self):
191 """
213 """
192 Gets opening post's pub time because thread does not have its own one.
214 Gets opening post's pub time because thread does not have its own one.
193 """
215 """
194
216
195 return self.get_opening_post().pub_time
217 return self.get_opening_post().pub_time
196
218
197 def __str__(self):
219 def __str__(self):
198 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
220 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
199
221
200 def get_tag_url_list(self) -> list:
222 def get_tag_url_list(self) -> list:
201 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
223 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
202
224
203 def update_posts_time(self, exclude_posts=None):
225 def update_posts_time(self, exclude_posts=None):
204 last_edit_time = self.last_edit_time
226 last_edit_time = self.last_edit_time
205
227
206 for post in self.post_set.all():
228 for post in self.multi_replies.all():
207 if exclude_posts is None or post not in exclude_posts:
229 if exclude_posts is None or post not in exclude_posts:
208 # Manual update is required because uids are generated on save
230 # Manual update is required because uids are generated on save
209 post.last_edit_time = last_edit_time
231 post.last_edit_time = last_edit_time
210 post.save(update_fields=['last_edit_time'])
232 post.save(update_fields=['last_edit_time'])
211
233
212 post.get_threads().update(last_edit_time=last_edit_time)
234 post.get_threads().update(last_edit_time=last_edit_time)
213
235
214 def notify_clients(self):
236 def notify_clients(self):
215 if not settings.get_bool('External', 'WebsocketsEnabled'):
237 if not settings.get_bool('External', 'WebsocketsEnabled'):
216 return
238 return
217
239
218 client = Client()
240 client = Client()
219
241
220 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
242 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
221 client.publish(channel_name, {
243 client.publish(channel_name, {
222 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
244 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
223 })
245 })
224 client.send()
246 client.send()
225
247
226 def get_absolute_url(self):
248 def get_absolute_url(self):
227 return self.get_opening_post().get_absolute_url()
249 return self.get_opening_post().get_absolute_url()
228
250
229 def get_required_tags(self):
251 def get_required_tags(self):
230 return self.get_tags().filter(required=True)
252 return self.get_tags().filter(required=True)
253
254 def get_replies_newer(self, post_id):
255 return self.get_replies().filter(id__gt=post_id)
256
257 def is_archived(self):
258 return self.archived
@@ -1,136 +1,145 b''
1 .ui-button {
1 .ui-button {
2 display: none;
2 display: none;
3 }
3 }
4
4
5 .ui-dialog-content {
5 .ui-dialog-content {
6 padding: 0;
6 padding: 0;
7 min-height: 0;
7 min-height: 0;
8 }
8 }
9
9
10 .mark_btn {
10 .mark_btn {
11 cursor: pointer;
11 cursor: pointer;
12 }
12 }
13
13
14 .img-full {
14 .img-full {
15 position: fixed;
15 position: fixed;
16 background-color: #CCC;
16 background-color: #CCC;
17 border: 1px solid #000;
17 border: 1px solid #000;
18 cursor: pointer;
18 cursor: pointer;
19 }
19 }
20
20
21 .strikethrough {
21 .strikethrough {
22 text-decoration: line-through;
22 text-decoration: line-through;
23 }
23 }
24
24
25 .post_preview {
25 .post_preview {
26 z-index: 300;
26 z-index: 300;
27 position:absolute;
27 position:absolute;
28 }
28 }
29
29
30 .gallery_image {
30 .gallery_image {
31 display: inline-block;
31 display: inline-block;
32 }
32 }
33
33
34 @media print {
34 @media print {
35 .post-form-w {
35 .post-form-w {
36 display: none;
36 display: none;
37 }
37 }
38 }
38 }
39
39
40 input[name="image"] {
40 input[name="image"] {
41 display: block;
41 display: block;
42 width: 100px;
42 width: 100px;
43 height: 100px;
43 height: 100px;
44 cursor: pointer;
44 cursor: pointer;
45 position: absolute;
45 position: absolute;
46 opacity: 0;
46 opacity: 0;
47 z-index: 1;
47 z-index: 1;
48 }
48 }
49
49
50 .file_wrap {
50 .file_wrap {
51 width: 100px;
51 width: 100px;
52 height: 100px;
52 height: 100px;
53 border: solid 1px white;
53 border: solid 1px white;
54 display: inline-block;
54 display: inline-block;
55 }
55 }
56
56
57 form > .file_wrap {
57 form > .file_wrap {
58 float: left;
58 float: left;
59 }
59 }
60
60
61 .file-thumb {
61 .file-thumb {
62 width: 100px;
62 width: 100px;
63 height: 100px;
63 height: 100px;
64 background-size: cover;
64 background-size: cover;
65 background-position: center;
65 background-position: center;
66 }
66 }
67
67
68 .compact-form-text {
68 .compact-form-text {
69 margin-left:110px;
69 margin-left:110px;
70 }
70 }
71
71
72 textarea, input {
72 textarea, input {
73 -moz-box-sizing: border-box;
73 -moz-box-sizing: border-box;
74 -webkit-box-sizing: border-box;
74 -webkit-box-sizing: border-box;
75 box-sizing: border-box;
75 box-sizing: border-box;
76 }
76 }
77
77
78 .compact-form-text > textarea {
78 .compact-form-text > textarea {
79 height: 100px;
79 height: 100px;
80 width: 100%;
80 width: 100%;
81 }
81 }
82
82
83 .post-button-form {
83 .post-button-form {
84 display: inline;
84 display: inline;
85 }
85 }
86
86
87 .post-button-form > button, #autoupdate {
87 .post-button-form > button, #autoupdate {
88 border: none;
88 border: none;
89 margin: inherit;
89 margin: inherit;
90 padding: inherit;
90 padding: inherit;
91 background: none;
91 background: none;
92 font-size: inherit;
92 font-size: inherit;
93 }
93 }
94
94
95 #form-close-button {
95 #form-close-button {
96 display: none;
96 display: none;
97 }
97 }
98
98
99 .post-image-full {
99 .post-image-full {
100 width: 100%;
100 width: 100%;
101 height: auto;
101 height: auto;
102 }
102 }
103
103
104 #preview-text {
104 #preview-text {
105 display: none;
105 display: none;
106 }
106 }
107
107
108 .random-images-table {
108 .random-images-table {
109 text-align: center;
109 text-align: center;
110 width: 100%;
110 width: 100%;
111 }
111 }
112
112
113 .random-images-table > div {
113 .random-images-table > div {
114 margin-left: auto;
114 margin-left: auto;
115 margin-right: auto;
115 margin-right: auto;
116 }
116 }
117
117
118 .tag-image, .tag-text-data {
118 .tag-image, .tag-text-data {
119 display: inline-block;
119 display: inline-block;
120 }
120 }
121
121
122 .tag-text-data > h2 {
122 .tag-text-data > h2 {
123 margin: 0;
123 margin: 0;
124 }
124 }
125
125
126 .tag-image {
126 .tag-image {
127 margin-right: 5px;
127 margin-right: 5px;
128 }
128 }
129
129
130 .reply-to-message {
130 .reply-to-message {
131 display: none;
131 display: none;
132 }
132 }
133
133
134 .tripcode {
134 .tripcode {
135 padding: 2px;
135 padding: 2px;
136 }
137
138 #fav-panel {
139 display: none;
140 margin: 1ex;
141 }
142
143 #new-fav-post-count {
144 display: none;
136 } No newline at end of file
145 }
@@ -1,559 +1,579 b''
1 * {
1 * {
2 text-decoration: none;
2 text-decoration: none;
3 font-weight: inherit;
3 font-weight: inherit;
4 }
4 }
5
5
6 b, strong {
6 b, strong {
7 font-weight: bold;
7 font-weight: bold;
8 }
8 }
9
9
10 html {
10 html {
11 background: #555;
11 background: #555;
12 color: #ffffff;
12 color: #ffffff;
13 }
13 }
14
14
15 body {
15 body {
16 margin: 0;
16 margin: 0;
17 }
17 }
18
18
19 #admin_panel {
19 #admin_panel {
20 background: #FF0000;
20 background: #FF0000;
21 color: #00FF00
21 color: #00FF00
22 }
22 }
23
23
24 .input_field_error {
24 .input_field_error {
25 color: #FF0000;
25 color: #FF0000;
26 }
26 }
27
27
28 .title {
28 .title {
29 font-weight: bold;
29 font-weight: bold;
30 color: #ffcc00;
30 color: #ffcc00;
31 }
31 }
32
32
33 .link, a {
33 .link, a {
34 color: #afdcec;
34 color: #afdcec;
35 }
35 }
36
36
37 .block {
37 .block {
38 display: inline-block;
38 display: inline-block;
39 vertical-align: top;
39 vertical-align: top;
40 }
40 }
41
41
42 .tag {
42 .tag {
43 color: #FFD37D;
43 color: #FFD37D;
44 }
44 }
45
45
46 .post_id {
46 .post_id {
47 color: #fff380;
47 color: #fff380;
48 }
48 }
49
49
50 .post, .dead_post, .archive_post, #posts-table {
50 .post, .dead_post, .archive_post, #posts-table {
51 background: #333;
51 background: #333;
52 padding: 10px;
52 padding: 10px;
53 clear: left;
53 clear: left;
54 word-wrap: break-word;
54 word-wrap: break-word;
55 border-top: 1px solid #777;
55 border-top: 1px solid #777;
56 border-bottom: 1px solid #777;
56 border-bottom: 1px solid #777;
57 }
57 }
58
58
59 .post + .post {
59 .post + .post {
60 border-top: none;
60 border-top: none;
61 }
61 }
62
62
63 .dead_post + .dead_post {
63 .dead_post + .dead_post {
64 border-top: none;
64 border-top: none;
65 }
65 }
66
66
67 .archive_post + .archive_post {
67 .archive_post + .archive_post {
68 border-top: none;
68 border-top: none;
69 }
69 }
70
70
71 .metadata {
71 .metadata {
72 padding-top: 5px;
72 padding-top: 5px;
73 margin-top: 10px;
73 margin-top: 10px;
74 border-top: solid 1px #666;
74 border-top: solid 1px #666;
75 color: #ddd;
75 color: #ddd;
76 }
76 }
77
77
78 .navigation_panel, .tag_info {
78 .navigation_panel, .tag_info {
79 background: #222;
79 background: #222;
80 margin-bottom: 5px;
80 margin-bottom: 5px;
81 margin-top: 5px;
81 margin-top: 5px;
82 padding: 10px;
82 padding: 10px;
83 border-bottom: solid 1px #888;
83 border-bottom: solid 1px #888;
84 border-top: solid 1px #888;
84 border-top: solid 1px #888;
85 color: #eee;
85 color: #eee;
86 }
86 }
87
87
88 .navigation_panel .link:first-child {
88 .navigation_panel .link:first-child {
89 border-right: 1px solid #fff;
89 border-right: 1px solid #fff;
90 font-weight: bold;
90 font-weight: bold;
91 margin-right: 1ex;
91 margin-right: 1ex;
92 padding-right: 1ex;
92 padding-right: 1ex;
93 }
93 }
94
94
95 .navigation_panel .right-link {
95 .navigation_panel .right-link {
96 border-left: 1px solid #fff;
96 border-left: 1px solid #fff;
97 border-right: none;
97 border-right: none;
98 float: right;
98 float: right;
99 margin-left: 1ex;
99 margin-left: 1ex;
100 margin-right: 0;
100 margin-right: 0;
101 padding-left: 1ex;
101 padding-left: 1ex;
102 padding-right: 0;
102 padding-right: 0;
103 }
103 }
104
104
105 .navigation_panel .link {
105 .navigation_panel .link {
106 font-weight: bold;
106 font-weight: bold;
107 }
107 }
108
108
109 .navigation_panel::after, .post::after {
109 .navigation_panel::after, .post::after {
110 clear: both;
110 clear: both;
111 content: ".";
111 content: ".";
112 display: block;
112 display: block;
113 height: 0;
113 height: 0;
114 line-height: 0;
114 line-height: 0;
115 visibility: hidden;
115 visibility: hidden;
116 }
116 }
117
117
118 .tag_info {
119 text-align: center;
120 }
121
122 .tag_info > .tag-text-data {
123 text-align: left;
124 }
125
118 .header {
126 .header {
119 border-bottom: solid 2px #ccc;
127 border-bottom: solid 2px #ccc;
120 margin-bottom: 5px;
128 margin-bottom: 5px;
121 border-top: none;
129 border-top: none;
122 margin-top: 0;
130 margin-top: 0;
123 }
131 }
124
132
125 .footer {
133 .footer {
126 border-top: solid 2px #ccc;
134 border-top: solid 2px #ccc;
127 margin-top: 5px;
135 margin-top: 5px;
128 border-bottom: none;
136 border-bottom: none;
129 margin-bottom: 0;
137 margin-bottom: 0;
130 }
138 }
131
139
132 p, .br {
140 p, .br {
133 margin-top: .5em;
141 margin-top: .5em;
134 margin-bottom: .5em;
142 margin-bottom: .5em;
135 }
143 }
136
144
137 .post-form-w {
145 .post-form-w {
138 background: #333344;
146 background: #333344;
139 border-top: solid 1px #888;
147 border-top: solid 1px #888;
140 border-bottom: solid 1px #888;
148 border-bottom: solid 1px #888;
141 color: #fff;
149 color: #fff;
142 padding: 10px;
150 padding: 10px;
143 margin-bottom: 5px;
151 margin-bottom: 5px;
144 margin-top: 5px;
152 margin-top: 5px;
145 }
153 }
146
154
147 .form-row {
155 .form-row {
148 width: 100%;
156 width: 100%;
149 display: table-row;
157 display: table-row;
150 }
158 }
151
159
152 .form-label {
160 .form-label {
153 padding: .25em 1ex .25em 0;
161 padding: .25em 1ex .25em 0;
154 vertical-align: top;
162 vertical-align: top;
155 display: table-cell;
163 display: table-cell;
156 }
164 }
157
165
158 .form-input {
166 .form-input {
159 padding: .25em 0;
167 padding: .25em 0;
160 width: 100%;
168 width: 100%;
161 display: table-cell;
169 display: table-cell;
162 }
170 }
163
171
164 .form-errors {
172 .form-errors {
165 font-weight: bolder;
173 font-weight: bolder;
166 vertical-align: middle;
174 vertical-align: middle;
167 display: table-cell;
175 display: table-cell;
168 }
176 }
169
177
170 .post-form input:not([name="image"]):not([type="checkbox"]):not([type="submit"]), .post-form textarea, .post-form select {
178 .post-form input:not([name="image"]):not([type="checkbox"]):not([type="submit"]), .post-form textarea, .post-form select {
171 background: #333;
179 background: #333;
172 color: #fff;
180 color: #fff;
173 border: solid 1px;
181 border: solid 1px;
174 padding: 0;
182 padding: 0;
175 font: medium sans-serif;
183 font: medium sans-serif;
176 width: 100%;
184 width: 100%;
177 }
185 }
178
186
179 .post-form textarea {
187 .post-form textarea {
180 resize: vertical;
188 resize: vertical;
181 }
189 }
182
190
183 .form-submit {
191 .form-submit {
184 display: table;
192 display: table;
185 margin-bottom: 1ex;
193 margin-bottom: 1ex;
186 margin-top: 1ex;
194 margin-top: 1ex;
187 }
195 }
188
196
189 .form-title {
197 .form-title {
190 font-weight: bold;
198 font-weight: bold;
191 font-size: 2ex;
199 font-size: 2ex;
192 margin-bottom: 0.5ex;
200 margin-bottom: 0.5ex;
193 }
201 }
194
202
195 input[type="submit"], button {
203 input[type="submit"], button {
196 background: #222;
204 background: #222;
197 border: solid 2px #fff;
205 border: solid 2px #fff;
198 color: #fff;
206 color: #fff;
199 padding: 0.5ex;
207 padding: 0.5ex;
200 margin-right: 0.5ex;
208 margin-right: 0.5ex;
201 }
209 }
202
210
203 input[type="submit"]:hover {
211 input[type="submit"]:hover {
204 background: #060;
212 background: #060;
205 }
213 }
206
214
207 .form-submit > button:hover {
215 .form-submit > button:hover {
208 background: #006;
216 background: #006;
209 }
217 }
210
218
211 blockquote {
219 blockquote {
212 border-left: solid 2px;
220 border-left: solid 2px;
213 padding-left: 5px;
221 padding-left: 5px;
214 color: #B1FB17;
222 color: #B1FB17;
215 margin: 0;
223 margin: 0;
216 }
224 }
217
225
218 .post > .image {
226 .post > .image {
219 float: left;
227 float: left;
220 margin: 0 1ex .5ex 0;
228 margin: 0 1ex .5ex 0;
221 min-width: 1px;
229 min-width: 1px;
222 text-align: center;
230 text-align: center;
223 display: table-row;
231 display: table-row;
224 }
232 }
225
233
226 .post > .metadata {
234 .post > .metadata {
227 clear: left;
235 clear: left;
228 }
236 }
229
237
230 .get {
238 .get {
231 font-weight: bold;
239 font-weight: bold;
232 color: #d55;
240 color: #d55;
233 }
241 }
234
242
235 * {
243 * {
236 text-decoration: none;
244 text-decoration: none;
237 }
245 }
238
246
239 .dead_post > .post-info {
247 .dead_post > .post-info {
240 font-style: italic;
248 font-style: italic;
241 }
249 }
242
250
243 .archive_post > .post-info {
251 .archive_post > .post-info {
244 text-decoration: line-through;
252 text-decoration: line-through;
245 }
253 }
246
254
247 .mark_btn {
255 .mark_btn {
248 border: 1px solid;
256 border: 1px solid;
249 padding: 2px 2ex;
257 padding: 2px 2ex;
250 display: inline-block;
258 display: inline-block;
251 margin: 0 5px 4px 0;
259 margin: 0 5px 4px 0;
252 }
260 }
253
261
254 .mark_btn:hover {
262 .mark_btn:hover {
255 background: #555;
263 background: #555;
256 }
264 }
257
265
258 .quote {
266 .quote {
259 color: #92cf38;
267 color: #92cf38;
260 font-style: italic;
268 font-style: italic;
261 }
269 }
262
270
263 .multiquote {
271 .multiquote {
264 padding: 3px;
272 padding: 3px;
265 display: inline-block;
273 display: inline-block;
266 background: #222;
274 background: #222;
267 border-style: solid;
275 border-style: solid;
268 border-width: 1px 1px 1px 4px;
276 border-width: 1px 1px 1px 4px;
269 font-size: 0.9em;
277 font-size: 0.9em;
270 }
278 }
271
279
272 .spoiler {
280 .spoiler {
273 background: black;
281 background: black;
274 color: black;
282 color: black;
275 padding: 0 1ex 0 1ex;
283 padding: 0 1ex 0 1ex;
276 }
284 }
277
285
278 .spoiler:hover {
286 .spoiler:hover {
279 color: #ddd;
287 color: #ddd;
280 }
288 }
281
289
282 .comment {
290 .comment {
283 color: #eb2;
291 color: #eb2;
284 }
292 }
285
293
286 a:hover {
294 a:hover {
287 text-decoration: underline;
295 text-decoration: underline;
288 }
296 }
289
297
290 .last-replies {
298 .last-replies {
291 margin-left: 3ex;
299 margin-left: 3ex;
292 margin-right: 3ex;
300 margin-right: 3ex;
293 border-left: solid 1px #777;
301 border-left: solid 1px #777;
294 border-right: solid 1px #777;
302 border-right: solid 1px #777;
295 }
303 }
296
304
297 .last-replies > .post:first-child {
305 .last-replies > .post:first-child {
298 border-top: none;
306 border-top: none;
299 }
307 }
300
308
301 .thread {
309 .thread {
302 margin-bottom: 3ex;
310 margin-bottom: 3ex;
303 margin-top: 1ex;
311 margin-top: 1ex;
304 }
312 }
305
313
306 .post:target {
314 .post:target {
307 border: solid 2px white;
315 border: solid 2px white;
308 }
316 }
309
317
310 pre{
318 pre{
311 white-space:pre-wrap
319 white-space:pre-wrap
312 }
320 }
313
321
314 li {
322 li {
315 list-style-position: inside;
323 list-style-position: inside;
316 }
324 }
317
325
318 .fancybox-skin {
326 .fancybox-skin {
319 position: relative;
327 position: relative;
320 background-color: #fff;
328 background-color: #fff;
321 color: #ddd;
329 color: #ddd;
322 text-shadow: none;
330 text-shadow: none;
323 }
331 }
324
332
325 .fancybox-image {
333 .fancybox-image {
326 border: 1px solid black;
334 border: 1px solid black;
327 }
335 }
328
336
329 .image-mode-tab {
337 .image-mode-tab {
330 background: #444;
338 background: #444;
331 color: #eee;
339 color: #eee;
332 margin-top: 5px;
340 margin-top: 5px;
333 padding: 5px;
341 padding: 5px;
334 border-top: 1px solid #888;
342 border-top: 1px solid #888;
335 border-bottom: 1px solid #888;
343 border-bottom: 1px solid #888;
336 }
344 }
337
345
338 .image-mode-tab > label {
346 .image-mode-tab > label {
339 margin: 0 1ex;
347 margin: 0 1ex;
340 }
348 }
341
349
342 .image-mode-tab > label > input {
350 .image-mode-tab > label > input {
343 margin-right: .5ex;
351 margin-right: .5ex;
344 }
352 }
345
353
346 #posts-table {
354 #posts-table {
347 margin-top: 5px;
355 margin-top: 5px;
348 margin-bottom: 5px;
356 margin-bottom: 5px;
349 }
357 }
350
358
351 .tag_info > h2 {
359 .tag_info > h2 {
352 margin: 0;
360 margin: 0;
353 }
361 }
354
362
355 .post-info {
363 .post-info {
356 color: #ddd;
364 color: #ddd;
357 margin-bottom: 1ex;
365 margin-bottom: 1ex;
358 }
366 }
359
367
360 .moderator_info {
368 .moderator_info {
361 color: #e99d41;
369 color: #e99d41;
362 opacity: 0.4;
370 opacity: 0.4;
363 }
371 }
364
372
365 .moderator_info:hover {
373 .moderator_info:hover {
366 opacity: 1;
374 opacity: 1;
367 }
375 }
368
376
369 .refmap {
377 .refmap {
370 font-size: 0.9em;
378 font-size: 0.9em;
371 color: #ccc;
379 color: #ccc;
372 margin-top: 1em;
380 margin-top: 1em;
373 }
381 }
374
382
375 .fav {
383 .fav {
376 color: yellow;
384 color: yellow;
377 }
385 }
378
386
379 .not_fav {
387 .not_fav {
380 color: #ccc;
388 color: #ccc;
381 }
389 }
382
390
383 .role {
391 .role {
384 text-decoration: underline;
392 text-decoration: underline;
385 }
393 }
386
394
387 .form-email {
395 .form-email {
388 display: none;
396 display: none;
389 }
397 }
390
398
391 .bar-value {
399 .bar-value {
392 background: rgba(50, 55, 164, 0.45);
400 background: rgba(50, 55, 164, 0.45);
393 font-size: 0.9em;
401 font-size: 0.9em;
394 height: 1.5em;
402 height: 1.5em;
395 }
403 }
396
404
397 .bar-bg {
405 .bar-bg {
398 position: relative;
406 position: relative;
399 border-top: solid 1px #888;
407 border-top: solid 1px #888;
400 border-bottom: solid 1px #888;
408 border-bottom: solid 1px #888;
401 margin-top: 5px;
409 margin-top: 5px;
402 overflow: hidden;
410 overflow: hidden;
403 }
411 }
404
412
405 .bar-text {
413 .bar-text {
406 padding: 2px;
414 padding: 2px;
407 position: absolute;
415 position: absolute;
408 left: 0;
416 left: 0;
409 top: 0;
417 top: 0;
410 }
418 }
411
419
412 .page_link {
420 .page_link {
413 background: #444;
421 background: #444;
414 border-top: solid 1px #888;
422 border-top: solid 1px #888;
415 border-bottom: solid 1px #888;
423 border-bottom: solid 1px #888;
416 padding: 5px;
424 padding: 5px;
417 color: #eee;
425 color: #eee;
418 font-size: 2ex;
426 font-size: 2ex;
427 margin-top: .5ex;
428 margin-bottom: .5ex;
419 }
429 }
420
430
421 .skipped_replies {
431 .skipped_replies {
422 padding: 5px;
432 padding: 5px;
423 margin-left: 3ex;
433 margin-left: 3ex;
424 margin-right: 3ex;
434 margin-right: 3ex;
425 border-left: solid 1px #888;
435 border-left: solid 1px #888;
426 border-right: solid 1px #888;
436 border-right: solid 1px #888;
427 border-bottom: solid 1px #888;
437 border-bottom: solid 1px #888;
428 background: #000;
438 background: #000;
429 }
439 }
430
440
431 .current_page {
441 .current_page {
432 padding: 2px;
442 padding: 2px;
433 background-color: #afdcec;
443 background-color: #afdcec;
434 color: #000;
444 color: #000;
435 }
445 }
436
446
437 .current_mode {
447 .current_mode {
438 font-weight: bold;
448 font-weight: bold;
439 }
449 }
440
450
441 .gallery_image {
451 .gallery_image {
442 border: solid 1px;
452 border: solid 1px;
443 margin: 0.5ex;
453 margin: 0.5ex;
444 text-align: center;
454 text-align: center;
455 padding: 1ex;
445 }
456 }
446
457
447 code {
458 code {
448 border: dashed 1px #ccc;
459 border: dashed 1px #ccc;
449 background: #111;
460 background: #111;
450 padding: 2px;
461 padding: 2px;
451 font-size: 1.2em;
462 font-size: 1.2em;
452 display: inline-block;
463 display: inline-block;
453 }
464 }
454
465
455 pre {
466 pre {
456 overflow: auto;
467 overflow: auto;
457 }
468 }
458
469
459 .img-full {
470 .img-full {
460 background: #222;
471 background: #222;
461 border: solid 1px white;
472 border: solid 1px white;
462 }
473 }
463
474
464 .tag_item {
475 .tag_item {
465 display: inline-block;
476 display: inline-block;
466 }
477 }
467
478
468 #id_models li {
479 #id_models li {
469 list-style: none;
480 list-style: none;
470 }
481 }
471
482
472 #id_q {
483 #id_q {
473 margin-left: 1ex;
484 margin-left: 1ex;
474 }
485 }
475
486
476 ul {
487 ul {
477 padding-left: 0px;
488 padding-left: 0px;
478 }
489 }
479
490
480 .quote-header {
491 .quote-header {
481 border-bottom: 2px solid #ddd;
492 border-bottom: 2px solid #ddd;
482 margin-bottom: 1ex;
493 margin-bottom: 1ex;
483 padding-bottom: .5ex;
494 padding-bottom: .5ex;
484 color: #ddd;
495 color: #ddd;
485 font-size: 1.2em;
496 font-size: 1.2em;
486 }
497 }
487
498
488 .global-id {
499 .global-id {
489 font-weight: bolder;
500 font-weight: bolder;
490 opacity: .5;
501 opacity: .5;
491 }
502 }
492
503
493 /* Post */
504 /* Post */
494 .post > .message, .post > .image {
505 .post > .message, .post > .image {
495 padding-left: 1em;
506 padding-left: 1em;
496 }
507 }
497
508
498 /* Reflink preview */
509 /* Reflink preview */
499 .post_preview {
510 .post_preview {
500 border-left: 1px solid #777;
511 border-left: 1px solid #777;
501 border-right: 1px solid #777;
512 border-right: 1px solid #777;
502 max-width: 600px;
513 max-width: 600px;
503 }
514 }
504
515
505 /* Code highlighter */
516 /* Code highlighter */
506 .hljs {
517 .hljs {
507 color: #fff;
518 color: #fff;
508 background: #000;
519 background: #000;
509 display: inline-block;
520 display: inline-block;
510 }
521 }
511
522
512 .hljs, .hljs-subst, .hljs-tag .hljs-title, .lisp .hljs-title, .clojure .hljs-built_in, .nginx .hljs-title {
523 .hljs, .hljs-subst, .hljs-tag .hljs-title, .lisp .hljs-title, .clojure .hljs-built_in, .nginx .hljs-title {
513 color: #fff;
524 color: #fff;
514 }
525 }
515
526
516 #up {
527 #up {
517 position: fixed;
528 position: fixed;
518 bottom: 5px;
529 bottom: 5px;
519 right: 5px;
530 right: 5px;
520 border: 1px solid #777;
531 border: 1px solid #777;
521 background: #000;
532 background: #000;
522 padding: 4px;
533 padding: 4px;
534 opacity: 0.3;
535 }
536
537 #up:hover {
538 opacity: 1;
523 }
539 }
524
540
525 .user-cast {
541 .user-cast {
526 border: solid #ffffff 1px;
542 border: solid #ffffff 1px;
527 padding: .2ex;
543 padding: .2ex;
528 background: #152154;
544 background: #152154;
529 color: #fff;
545 color: #fff;
530 }
546 }
531
547
532 .highlight {
548 .highlight {
533 background: #222;
549 background: #222;
534 }
550 }
535
551
536 .post-button-form > button:hover {
552 .post-button-form > button:hover {
537 text-decoration: underline;
553 text-decoration: underline;
538 }
554 }
539
555
540 .tree_reply > .post {
556 .tree_reply > .post {
541 margin-top: 1ex;
557 margin-top: 1ex;
542 border-left: solid 1px #777;
558 border-left: solid 1px #777;
543 padding-right: 0;
559 padding-right: 0;
544 }
560 }
545
561
546 #preview-text {
562 #preview-text {
547 border: solid 1px white;
563 border: solid 1px white;
548 margin: 1ex 0 1ex 0;
564 margin: 1ex 0 1ex 0;
549 padding: 1ex;
565 padding: 1ex;
550 }
566 }
551
567
552 .image-metadata {
568 .image-metadata {
553 font-style: italic;
569 font-style: italic;
554 font-size: 0.9em;
570 font-size: 0.9em;
555 }
571 }
556
572
557 .tripcode {
573 .tripcode {
558 color: white;
574 color: white;
559 }
575 }
576
577 #fav-panel {
578 border: 1px solid white;
579 }
@@ -1,160 +1,181 b''
1 /*
1 /*
2 @licstart The following is the entire license notice for the
2 @licstart The following is the entire license notice for the
3 JavaScript code in this page.
3 JavaScript code in this page.
4
4
5
5
6 Copyright (C) 2013 neko259
6 Copyright (C) 2013 neko259
7
7
8 The JavaScript code in this page is free software: you can
8 The JavaScript code in this page is free software: you can
9 redistribute it and/or modify it under the terms of the GNU
9 redistribute it and/or modify it under the terms of the GNU
10 General Public License (GNU GPL) as published by the Free Software
10 General Public License (GNU GPL) as published by the Free Software
11 Foundation, either version 3 of the License, or (at your option)
11 Foundation, either version 3 of the License, or (at your option)
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
13 without even the implied warranty of MERCHANTABILITY or FITNESS
13 without even the implied warranty of MERCHANTABILITY or FITNESS
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
15
15
16 As additional permission under GNU GPL version 3 section 7, you
16 As additional permission under GNU GPL version 3 section 7, you
17 may distribute non-source (e.g., minimized or compacted) forms of
17 may distribute non-source (e.g., minimized or compacted) forms of
18 that code without the copy of the GNU GPL normally required by
18 that code without the copy of the GNU GPL normally required by
19 section 4, provided you include this license notice and a URL
19 section 4, provided you include this license notice and a URL
20 through which recipients can access the Corresponding Source.
20 through which recipients can access the Corresponding Source.
21
21
22 @licend The above is the entire license notice
22 @licend The above is the entire license notice
23 for the JavaScript code in this page.
23 for the JavaScript code in this page.
24 */
24 */
25
25
26 var IMAGE_POPUP_MARGIN = 10;
27
26
28
27 var IMAGE_VIEWERS = [
29 var IMAGE_VIEWERS = [
28 ['simple', new SimpleImageViewer()],
30 ['simple', new SimpleImageViewer()],
29 ['popup', new PopupImageViewer()]
31 ['popup', new PopupImageViewer()]
30 ];
32 ];
31
33
32 var FULL_IMG_CLASS = 'post-image-full';
34 var FULL_IMG_CLASS = 'post-image-full';
33
35
36 var ATTR_SCALE = 'scale';
37
34
38
35 function ImageViewer() {}
39 function ImageViewer() {}
36 ImageViewer.prototype.view = function (post) {};
40 ImageViewer.prototype.view = function (post) {};
37
41
38 function SimpleImageViewer() {}
42 function SimpleImageViewer() {}
39 SimpleImageViewer.prototype.view = function (post) {
43 SimpleImageViewer.prototype.view = function (post) {
40 var images = post.find('img');
44 var images = post.find('img');
41 images.toggle();
45 images.toggle();
42
46
43 // When we first enlarge an image, a full image needs to be created
47 // When we first enlarge an image, a full image needs to be created
44 if (images.length == 1) {
48 if (images.length == 1) {
45 var thumb = images.first();
49 var thumb = images.first();
46
50
47 var width = thumb.attr('data-width');
51 var width = thumb.attr('data-width');
48 var height = thumb.attr('data-height');
52 var height = thumb.attr('data-height');
49
53
50 if (width == null || height == null) {
54 if (width == null || height == null) {
51 width = '100%';
55 width = '100%';
52 height = '100%';
56 height = '100%';
53 }
57 }
54
58
55 var parent = images.first().parent();
59 var parent = images.first().parent();
56 var link = parent.attr('href');
60 var link = parent.attr('href');
57
61
58 var fullImg = $('<img />')
62 var fullImg = $('<img />')
59 .addClass(FULL_IMG_CLASS)
63 .addClass(FULL_IMG_CLASS)
60 .attr('src', link)
64 .attr('src', link)
61 .attr('width', width)
65 .attr('width', width)
62 .attr('height', height);
66 .attr('height', height);
63
67
64 parent.append(fullImg);
68 parent.append(fullImg);
65 }
69 }
66 };
70 };
67
71
68 function PopupImageViewer() {}
72 function PopupImageViewer() {}
69 PopupImageViewer.prototype.view = function (post) {
73 PopupImageViewer.prototype.view = function (post) {
70 var margin = 20; //..change
71
72 var el = post;
74 var el = post;
73 var thumb_id = 'full' + el.find('img').attr('alt');
75 var thumb_id = 'full' + el.find('img').attr('alt');
74
76
75 var existingPopups = $('#' + thumb_id);
77 var existingPopups = $('#' + thumb_id);
76 if(!existingPopups.length) {
78 if (!existingPopups.length) {
77 var imgElement= el.find('img');
79 var imgElement= el.find('img');
78
80
79 var img_w = imgElement.attr('data-width');
81 var full_img_w = imgElement.attr('data-width');
80 var img_h = imgElement.attr('data-height');
82 var full_img_h = imgElement.attr('data-height');
81
83
82 var win = $(window);
84 var win = $(window);
83
85
84 var win_w = win.width();
86 var win_w = win.width();
85 var win_h = win.height();
87 var win_h = win.height();
86 //new image size
88
87 if (img_w > win_w) {
89 // New image size
88 img_h = img_h * (win_w/img_w) - margin;
90 var w_scale = 1;
89 img_w = win_w - margin;
91 var h_scale = 1;
92
93 var freeWidth = win_w - 2 * IMAGE_POPUP_MARGIN;
94 var freeHeight = win_h - 2 * IMAGE_POPUP_MARGIN;
95
96 if (full_img_w > freeWidth) {
97 w_scale = full_img_w / freeWidth;
90 }
98 }
91 if (img_h > win_h) {
99 if (full_img_h > freeHeight) {
92 img_w = img_w * (win_h/img_h) - margin;
100 h_scale = full_img_h / freeHeight;
93 img_h = win_h - margin;
94 }
101 }
95
102
103 var scale = Math.max(w_scale, h_scale)
104 var img_w = full_img_w / scale;
105 var img_h = full_img_h / scale;
106
107 var postNode = $(el);
108
96 var img_pv = new Image();
109 var img_pv = new Image();
97 var newImage = $(img_pv);
110 var newImage = $(img_pv);
98 newImage.addClass('img-full')
111 newImage.addClass('img-full')
99 .attr('id', thumb_id)
112 .attr('id', thumb_id)
100 .attr('src', $(el).attr('href'))
113 .attr('src', postNode.attr('href'))
101 .appendTo($(el))
114 .attr(ATTR_SCALE, scale)
115 .appendTo(postNode)
102 .css({
116 .css({
103 'width': img_w,
117 'width': img_w,
104 'height': img_h,
118 'height': img_h,
105 'left': (win_w - img_w) / 2,
119 'left': (win_w - img_w) / 2,
106 'top': ((win_h - img_h) / 2)
120 'top': ((win_h - img_h) / 2)
107 })
121 })
108 //scaling preview
122 //scaling preview
109 .mousewheel(function(event, delta) {
123 .mousewheel(function(event, delta) {
110 var cx = event.originalEvent.clientX,
124 var cx = event.originalEvent.clientX;
111 cy = event.originalEvent.clientY,
125 var cy = event.originalEvent.clientY;
112 i_w = parseFloat(newImage.width()),
126
113 i_h = parseFloat(newImage.height()),
127 var scale = newImage.attr(ATTR_SCALE) / (delta > 0 ? 1.25 : 0.8);
114 newIW = i_w * (delta > 0 ? 1.25 : 0.8),
128
115 newIH = i_h * (delta > 0 ? 1.25 : 0.8);
129 var oldWidth = newImage.width();
130 var oldHeight = newImage.height();
131
132 var newIW = full_img_w / scale;
133 var newIH = full_img_h / scale;
116
134
117 newImage.width(newIW);
135 newImage.width(newIW);
118 newImage.height(newIH);
136 newImage.height(newIH);
119 //set position
137 newImage.attr(ATTR_SCALE, scale);
138
139 // Set position
140 var oldPosition = newImage.position();
120 newImage.css({
141 newImage.css({
121 left: parseInt(cx - (newIW/i_w) * (cx - parseInt($(img_pv).position().left, 10)), 10),
142 left: parseInt(cx - (newIW/oldWidth) * (cx - parseInt(oldPosition.left, 10)), 10),
122 top: parseInt(cy - (newIH/i_h) * (cy - parseInt($(img_pv).position().top, 10)), 10)
143 top: parseInt(cy - (newIH/oldHeight) * (cy - parseInt(oldPosition.top, 10)), 10)
123 });
144 });
124
145
125 return false;
146 return false;
126 }
147 }
127 )
148 )
128 .draggable({
149 .draggable({
129 addClasses: false,
150 addClasses: false,
130 stack: '.img-full'
151 stack: '.img-full'
131 });
152 });
132 } else {
153 } else {
133 existingPopups.remove();
154 existingPopups.remove();
134 }
155 }
135 };
156 };
136
157
137 function addImgPreview() {
158 function addImgPreview() {
138 var viewerName = $('body').attr('data-image-viewer');
159 var viewerName = $('body').attr('data-image-viewer');
139 var viewer = ImageViewer();
160 var viewer = ImageViewer();
140 for (var i = 0; i < IMAGE_VIEWERS.length; i++) {
161 for (var i = 0; i < IMAGE_VIEWERS.length; i++) {
141 var item = IMAGE_VIEWERS[i];
162 var item = IMAGE_VIEWERS[i];
142 if (item[0] === viewerName) {
163 if (item[0] === viewerName) {
143 viewer = item[1];
164 viewer = item[1];
144 break;
165 break;
145 }
166 }
146 }
167 }
147
168
148 //keybind
169 //keybind
149 $(document).on('keyup.removepic', function(e) {
170 $(document).on('keyup.removepic', function(e) {
150 if(e.which === 27) {
171 if(e.which === 27) {
151 $('.img-full').remove();
172 $('.img-full').remove();
152 }
173 }
153 });
174 });
154
175
155 $('body').on('click', '.thumb', function() {
176 $('body').on('click', '.thumb', function() {
156 viewer.view($(this));
177 viewer.view($(this));
157
178
158 return false;
179 return false;
159 });
180 });
160 }
181 }
@@ -1,56 +1,126 b''
1 /*
1 /*
2 @licstart The following is the entire license notice for the
2 @licstart The following is the entire license notice for the
3 JavaScript code in this page.
3 JavaScript code in this page.
4
4
5
5
6 Copyright (C) 2013 neko259
6 Copyright (C) 2013 neko259
7
7
8 The JavaScript code in this page is free software: you can
8 The JavaScript code in this page is free software: you can
9 redistribute it and/or modify it under the terms of the GNU
9 redistribute it and/or modify it under the terms of the GNU
10 General Public License (GNU GPL) as published by the Free Software
10 General Public License (GNU GPL) as published by the Free Software
11 Foundation, either version 3 of the License, or (at your option)
11 Foundation, either version 3 of the License, or (at your option)
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
13 without even the implied warranty of MERCHANTABILITY or FITNESS
13 without even the implied warranty of MERCHANTABILITY or FITNESS
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
15
15
16 As additional permission under GNU GPL version 3 section 7, you
16 As additional permission under GNU GPL version 3 section 7, you
17 may distribute non-source (e.g., minimized or compacted) forms of
17 may distribute non-source (e.g., minimized or compacted) forms of
18 that code without the copy of the GNU GPL normally required by
18 that code without the copy of the GNU GPL normally required by
19 section 4, provided you include this license notice and a URL
19 section 4, provided you include this license notice and a URL
20 through which recipients can access the Corresponding Source.
20 through which recipients can access the Corresponding Source.
21
21
22 @licend The above is the entire license notice
22 @licend The above is the entire license notice
23 for the JavaScript code in this page.
23 for the JavaScript code in this page.
24 */
24 */
25
25
26 var FAV_POST_UPDATE_PERIOD = 10000;
27
26 /**
28 /**
27 * An email is a hidden file to prevent spam bots from posting. It has to be
29 * An email is a hidden file to prevent spam bots from posting. It has to be
28 * hidden.
30 * hidden.
29 */
31 */
30 function hideEmailFromForm() {
32 function hideEmailFromForm() {
31 $('.form-email').parent().parent().hide();
33 $('.form-email').parent().parent().hide();
32 }
34 }
33
35
34 /**
36 /**
35 * Highlight code blocks with code highlighter
37 * Highlight code blocks with code highlighter
36 */
38 */
37 function highlightCode(node) {
39 function highlightCode(node) {
38 node.find('pre code').each(function(i, e) {
40 node.find('pre code').each(function(i, e) {
39 hljs.highlightBlock(e);
41 hljs.highlightBlock(e);
40 });
42 });
41 }
43 }
42
44
45 function updateFavPosts() {
46 var includePostBody = $('#fav-panel').is(":visible");
47 var url = '/api/new_posts/';
48 if (includePostBody) {
49 url += '?include_posts'
50 }
51 $.getJSON(url,
52 function(data) {
53 var allNewPostCount = 0;
54
55 if (includePostBody) {
56 var favoriteThreadPanel = $('#fav-panel');
57 favoriteThreadPanel.empty();
58 }
59
60 $.each(data, function (_, dict) {
61 var newPostCount = dict.new_post_count;
62 allNewPostCount += newPostCount;
63
64 if (includePostBody) {
65 var favThreadNode = $('<div class="post"></div>');
66 favThreadNode.append($(dict.post_url));
67 favThreadNode.append(' ');
68 favThreadNode.append($('<span class="title">' + dict.title + '</span>'));
69
70 if (newPostCount > 0) {
71 favThreadNode.append(' (<a href="' + dict.newest_post_link + '">+' + newPostCount + "</a>)");
72 }
73
74 favoriteThreadPanel.append(favThreadNode);
75
76 addRefLinkPreview(favThreadNode[0]);
77 }
78 });
79
80 var newPostCountNode = $('#new-fav-post-count');
81 if (allNewPostCount > 0) {
82 newPostCountNode.text('(+' + allNewPostCount + ')');
83 newPostCountNode.show();
84 } else {
85 newPostCountNode.hide();
86 }
87 }
88 );
89 }
90
91 function initFavPanel() {
92 updateFavPosts();
93
94 if ($('#fav-panel-btn').length > 0) {
95 setInterval(updateFavPosts, FAV_POST_UPDATE_PERIOD);
96 $('#fav-panel-btn').click(function() {
97 $('#fav-panel').toggle();
98 updateFavPosts();
99
100 return false;
101 });
102
103 $(document).on('keyup.removepic', function(e) {
104 if(e.which === 27) {
105 $('#fav-panel').hide();
106 }
107 });
108 }
109 }
110
43 $( document ).ready(function() {
111 $( document ).ready(function() {
44 hideEmailFromForm();
112 hideEmailFromForm();
45
113
46 $("a[href='#top']").click(function() {
114 $("a[href='#top']").click(function() {
47 $("html, body").animate({ scrollTop: 0 }, "slow");
115 $("html, body").animate({ scrollTop: 0 }, "slow");
48 return false;
116 return false;
49 });
117 });
50
118
51 addImgPreview();
119 addImgPreview();
52
120
53 addRefLinkPreview();
121 addRefLinkPreview();
54
122
55 highlightCode($(document));
123 highlightCode($(document));
124
125 initFavPanel();
56 });
126 });
@@ -1,456 +1,457 b''
1 /*
1 /*
2 @licstart The following is the entire license notice for the
2 @licstart The following is the entire license notice for the
3 JavaScript code in this page.
3 JavaScript code in this page.
4
4
5
5
6 Copyright (C) 2013-2014 neko259
6 Copyright (C) 2013-2014 neko259
7
7
8 The JavaScript code in this page is free software: you can
8 The JavaScript code in this page is free software: you can
9 redistribute it and/or modify it under the terms of the GNU
9 redistribute it and/or modify it under the terms of the GNU
10 General Public License (GNU GPL) as published by the Free Software
10 General Public License (GNU GPL) as published by the Free Software
11 Foundation, either version 3 of the License, or (at your option)
11 Foundation, either version 3 of the License, or (at your option)
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
13 without even the implied warranty of MERCHANTABILITY or FITNESS
13 without even the implied warranty of MERCHANTABILITY or FITNESS
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
15
15
16 As additional permission under GNU GPL version 3 section 7, you
16 As additional permission under GNU GPL version 3 section 7, you
17 may distribute non-source (e.g., minimized or compacted) forms of
17 may distribute non-source (e.g., minimized or compacted) forms of
18 that code without the copy of the GNU GPL normally required by
18 that code without the copy of the GNU GPL normally required by
19 section 4, provided you include this license notice and a URL
19 section 4, provided you include this license notice and a URL
20 through which recipients can access the Corresponding Source.
20 through which recipients can access the Corresponding Source.
21
21
22 @licend The above is the entire license notice
22 @licend The above is the entire license notice
23 for the JavaScript code in this page.
23 for the JavaScript code in this page.
24 */
24 */
25
25
26 var CLASS_POST = '.post'
26 var CLASS_POST = '.post'
27
27
28 var POST_ADDED = 0;
28 var POST_ADDED = 0;
29 var POST_UPDATED = 1;
29 var POST_UPDATED = 1;
30
30
31 var JS_AUTOUPDATE_PERIOD = 20000;
31 var JS_AUTOUPDATE_PERIOD = 20000;
32
32
33 var ALLOWED_FOR_PARTIAL_UPDATE = [
33 var ALLOWED_FOR_PARTIAL_UPDATE = [
34 'refmap',
34 'refmap',
35 'post-info'
35 'post-info'
36 ];
36 ];
37
37
38 var ATTR_CLASS = 'class';
38 var ATTR_CLASS = 'class';
39 var ATTR_UID = 'data-uid';
39 var ATTR_UID = 'data-uid';
40
40
41 var wsUser = '';
41 var wsUser = '';
42
42
43 var unreadPosts = 0;
43 var unreadPosts = 0;
44 var documentOriginalTitle = '';
44 var documentOriginalTitle = '';
45
45
46 // Thread ID does not change, can be stored one time
46 // Thread ID does not change, can be stored one time
47 var threadId = $('div.thread').children(CLASS_POST).first().attr('id');
47 var threadId = $('div.thread').children(CLASS_POST).first().attr('id');
48
48
49 /**
49 /**
50 * Connect to websocket server and subscribe to thread updates. On any update we
50 * Connect to websocket server and subscribe to thread updates. On any update we
51 * request a thread diff.
51 * request a thread diff.
52 *
52 *
53 * @returns {boolean} true if connected, false otherwise
53 * @returns {boolean} true if connected, false otherwise
54 */
54 */
55 function connectWebsocket() {
55 function connectWebsocket() {
56 var metapanel = $('.metapanel')[0];
56 var metapanel = $('.metapanel')[0];
57
57
58 var wsHost = metapanel.getAttribute('data-ws-host');
58 var wsHost = metapanel.getAttribute('data-ws-host');
59 var wsPort = metapanel.getAttribute('data-ws-port');
59 var wsPort = metapanel.getAttribute('data-ws-port');
60
60
61 if (wsHost.length > 0 && wsPort.length > 0) {
61 if (wsHost.length > 0 && wsPort.length > 0) {
62 var centrifuge = new Centrifuge({
62 var centrifuge = new Centrifuge({
63 "url": 'ws://' + wsHost + ':' + wsPort + "/connection/websocket",
63 "url": 'ws://' + wsHost + ':' + wsPort + "/connection/websocket",
64 "project": metapanel.getAttribute('data-ws-project'),
64 "project": metapanel.getAttribute('data-ws-project'),
65 "user": wsUser,
65 "user": wsUser,
66 "timestamp": metapanel.getAttribute('data-ws-token-time'),
66 "timestamp": metapanel.getAttribute('data-ws-token-time'),
67 "token": metapanel.getAttribute('data-ws-token'),
67 "token": metapanel.getAttribute('data-ws-token'),
68 "debug": false
68 "debug": false
69 });
69 });
70
70
71 centrifuge.on('error', function(error_message) {
71 centrifuge.on('error', function(error_message) {
72 console.log("Error connecting to websocket server.");
72 console.log("Error connecting to websocket server.");
73 console.log(error_message);
73 console.log(error_message);
74 console.log("Using javascript update instead.");
74 console.log("Using javascript update instead.");
75
75
76 // If websockets don't work, enable JS update instead
76 // If websockets don't work, enable JS update instead
77 enableJsUpdate()
77 enableJsUpdate()
78 });
78 });
79
79
80 centrifuge.on('connect', function() {
80 centrifuge.on('connect', function() {
81 var channelName = 'thread:' + threadId;
81 var channelName = 'thread:' + threadId;
82 centrifuge.subscribe(channelName, function(message) {
82 centrifuge.subscribe(channelName, function(message) {
83 getThreadDiff();
83 getThreadDiff();
84 });
84 });
85
85
86 // For the case we closed the browser and missed some updates
86 // For the case we closed the browser and missed some updates
87 getThreadDiff();
87 getThreadDiff();
88 $('#autoupdate').hide();
88 $('#autoupdate').hide();
89 });
89 });
90
90
91 centrifuge.connect();
91 centrifuge.connect();
92
92
93 return true;
93 return true;
94 } else {
94 } else {
95 return false;
95 return false;
96 }
96 }
97 }
97 }
98
98
99 /**
99 /**
100 * Get diff of the posts from the current thread timestamp.
100 * Get diff of the posts from the current thread timestamp.
101 * This is required if the browser was closed and some post updates were
101 * This is required if the browser was closed and some post updates were
102 * missed.
102 * missed.
103 */
103 */
104 function getThreadDiff() {
104 function getThreadDiff() {
105 var lastUpdateTime = $('.metapanel').attr('data-last-update');
105 var all_posts = $('.post');
106 var lastPostId = $('.post').last().attr('id');
107
106
108 var uids = '';
107 var uids = '';
109 var posts = $('.post');
108 var posts = all_posts;
110 for (var i = 0; i < posts.length; i++) {
109 for (var i = 0; i < posts.length; i++) {
111 uids += posts[i].getAttribute('data-uid') + ' ';
110 uids += posts[i].getAttribute('data-uid') + ' ';
112 }
111 }
113
112
114 var data = {
113 var data = {
115 uids: uids,
114 uids: uids,
116 thread: threadId
115 thread: threadId
117 }
116 };
118
117
119 var diffUrl = '/api/diff_thread/';
118 var diffUrl = '/api/diff_thread/';
120
119
121 $.post(diffUrl,
120 $.post(diffUrl,
122 data,
121 data,
123 function(data) {
122 function(data) {
124 var updatedPosts = data.updated;
123 var updatedPosts = data.updated;
125 var addedPostCount = 0;
124 var addedPostCount = 0;
126
125
127 for (var i = 0; i < updatedPosts.length; i++) {
126 for (var i = 0; i < updatedPosts.length; i++) {
128 var postText = updatedPosts[i];
127 var postText = updatedPosts[i];
129 var post = $(postText);
128 var post = $(postText);
130
129
131 if (updatePost(post) == POST_ADDED) {
130 if (updatePost(post) == POST_ADDED) {
132 addedPostCount++;
131 addedPostCount++;
133 }
132 }
134 }
133 }
135
134
136 var hasMetaUpdates = updatedPosts.length > 0;
135 var hasMetaUpdates = updatedPosts.length > 0;
137 if (hasMetaUpdates) {
136 if (hasMetaUpdates) {
138 updateMetadataPanel();
137 updateMetadataPanel();
139 }
138 }
140
139
141 if (addedPostCount > 0) {
140 if (addedPostCount > 0) {
142 updateBumplimitProgress(addedPostCount);
141 updateBumplimitProgress(addedPostCount);
143 }
142 }
144
143
145 if (updatedPosts.length > 0) {
144 if (updatedPosts.length > 0) {
146 showNewPostsTitle(addedPostCount);
145 showNewPostsTitle(addedPostCount);
147 }
146 }
148
147
149 // TODO Process removed posts if any
148 // TODO Process removed posts if any
150 $('.metapanel').attr('data-last-update', data.last_update);
149 $('.metapanel').attr('data-last-update', data.last_update);
151 },
150 },
152 'json'
151 'json'
153 )
152 )
154 }
153 }
155
154
156 /**
155 /**
157 * Add or update the post on html page.
156 * Add or update the post on html page.
158 */
157 */
159 function updatePost(postHtml) {
158 function updatePost(postHtml) {
160 // This needs to be set on start because the page is scrolled after posts
159 // This needs to be set on start because the page is scrolled after posts
161 // are added or updated
160 // are added or updated
162 var bottom = isPageBottom();
161 var bottom = isPageBottom();
163
162
164 var post = $(postHtml);
163 var post = $(postHtml);
165
164
166 var threadBlock = $('div.thread');
165 var threadBlock = $('div.thread');
167
166
168 var postId = post.attr('id');
167 var postId = post.attr('id');
169
168
170 // If the post already exists, replace it. Otherwise add as a new one.
169 // If the post already exists, replace it. Otherwise add as a new one.
171 var existingPosts = threadBlock.children('.post[id=' + postId + ']');
170 var existingPosts = threadBlock.children('.post[id=' + postId + ']');
172
171
173 var type;
172 var type;
174
173
175 if (existingPosts.size() > 0) {
174 if (existingPosts.size() > 0) {
176 replacePartial(existingPosts.first(), post, false);
175 replacePartial(existingPosts.first(), post, false);
177 post = existingPosts.first();
176 post = existingPosts.first();
178
177
179 type = POST_UPDATED;
178 type = POST_UPDATED;
180 } else {
179 } else {
181 post.appendTo(threadBlock);
180 post.appendTo(threadBlock);
182
181
183 if (bottom) {
182 if (bottom) {
184 scrollToBottom();
183 scrollToBottom();
185 }
184 }
186
185
187 type = POST_ADDED;
186 type = POST_ADDED;
188 }
187 }
189
188
190 processNewPost(post);
189 processNewPost(post);
191
190
192 return type;
191 return type;
193 }
192 }
194
193
195 /**
194 /**
196 * Initiate a blinking animation on a node to show it was updated.
195 * Initiate a blinking animation on a node to show it was updated.
197 */
196 */
198 function blink(node) {
197 function blink(node) {
199 var blinkCount = 2;
198 var blinkCount = 2;
200
199
201 var nodeToAnimate = node;
200 var nodeToAnimate = node;
202 for (var i = 0; i < blinkCount; i++) {
201 for (var i = 0; i < blinkCount; i++) {
203 nodeToAnimate = nodeToAnimate.fadeTo('fast', 0.5).fadeTo('fast', 1.0);
202 nodeToAnimate = nodeToAnimate.fadeTo('fast', 0.5).fadeTo('fast', 1.0);
204 }
203 }
205 }
204 }
206
205
207 function isPageBottom() {
206 function isPageBottom() {
208 var scroll = $(window).scrollTop() / ($(document).height()
207 var scroll = $(window).scrollTop() / ($(document).height()
209 - $(window).height());
208 - $(window).height());
210
209
211 return scroll == 1
210 return scroll == 1
212 }
211 }
213
212
214 function enableJsUpdate() {
213 function enableJsUpdate() {
215 setInterval(getThreadDiff, JS_AUTOUPDATE_PERIOD);
214 setInterval(getThreadDiff, JS_AUTOUPDATE_PERIOD);
216 return true;
215 return true;
217 }
216 }
218
217
219 function initAutoupdate() {
218 function initAutoupdate() {
220 if (location.protocol === 'https:') {
219 if (location.protocol === 'https:') {
221 return enableJsUpdate();
220 return enableJsUpdate();
222 } else {
221 } else {
223 if (connectWebsocket()) {
222 if (connectWebsocket()) {
224 return true;
223 return true;
225 } else {
224 } else {
226 return enableJsUpdate();
225 return enableJsUpdate();
227 }
226 }
228 }
227 }
229 }
228 }
230
229
231 function getReplyCount() {
230 function getReplyCount() {
232 return $('.thread').children(CLASS_POST).length
231 return $('.thread').children(CLASS_POST).length
233 }
232 }
234
233
235 function getImageCount() {
234 function getImageCount() {
236 return $('.thread').find('img').length
235 return $('.thread').find('img').length
237 }
236 }
238
237
239 /**
238 /**
240 * Update post count, images count and last update time in the metadata
239 * Update post count, images count and last update time in the metadata
241 * panel.
240 * panel.
242 */
241 */
243 function updateMetadataPanel() {
242 function updateMetadataPanel() {
244 var replyCountField = $('#reply-count');
243 var replyCountField = $('#reply-count');
245 var imageCountField = $('#image-count');
244 var imageCountField = $('#image-count');
246
245
247 replyCountField.text(getReplyCount());
246 var replyCount = getReplyCount();
248 imageCountField.text(getImageCount());
247 replyCountField.text(replyCount);
248 var imageCount = getImageCount();
249 imageCountField.text(imageCount);
249
250
250 var lastUpdate = $('.post:last').children('.post-info').first()
251 var lastUpdate = $('.post:last').children('.post-info').first()
251 .children('.pub_time').first().html();
252 .children('.pub_time').first().html();
252 if (lastUpdate !== '') {
253 if (lastUpdate !== '') {
253 var lastUpdateField = $('#last-update');
254 var lastUpdateField = $('#last-update');
254 lastUpdateField.html(lastUpdate);
255 lastUpdateField.html(lastUpdate);
255 blink(lastUpdateField);
256 blink(lastUpdateField);
256 }
257 }
257
258
258 blink(replyCountField);
259 blink(replyCountField);
259 blink(imageCountField);
260 blink(imageCountField);
261
262 $('#message-count-text').text(ngettext('message', 'messages', replyCount));
263 $('#image-count-text').text(ngettext('image', 'images', imageCount));
260 }
264 }
261
265
262 /**
266 /**
263 * Update bumplimit progress bar
267 * Update bumplimit progress bar
264 */
268 */
265 function updateBumplimitProgress(postDelta) {
269 function updateBumplimitProgress(postDelta) {
266 var progressBar = $('#bumplimit_progress');
270 var progressBar = $('#bumplimit_progress');
267 if (progressBar) {
271 if (progressBar) {
268 var postsToLimitElement = $('#left_to_limit');
272 var postsToLimitElement = $('#left_to_limit');
269
273
270 var oldPostsToLimit = parseInt(postsToLimitElement.text());
274 var oldPostsToLimit = parseInt(postsToLimitElement.text());
271 var postCount = getReplyCount();
275 var postCount = getReplyCount();
272 var bumplimit = postCount - postDelta + oldPostsToLimit;
276 var bumplimit = postCount - postDelta + oldPostsToLimit;
273
277
274 var newPostsToLimit = bumplimit - postCount;
278 var newPostsToLimit = bumplimit - postCount;
275 if (newPostsToLimit <= 0) {
279 if (newPostsToLimit <= 0) {
276 $('.bar-bg').remove();
280 $('.bar-bg').remove();
277 } else {
281 } else {
278 postsToLimitElement.text(newPostsToLimit);
282 postsToLimitElement.text(newPostsToLimit);
279 progressBar.width((100 - postCount / bumplimit * 100.0) + '%');
283 progressBar.width((100 - postCount / bumplimit * 100.0) + '%');
280 }
284 }
281 }
285 }
282 }
286 }
283
287
284 /**
288 /**
285 * Show 'new posts' text in the title if the document is not visible to a user
289 * Show 'new posts' text in the title if the document is not visible to a user
286 */
290 */
287 function showNewPostsTitle(newPostCount) {
291 function showNewPostsTitle(newPostCount) {
288 if (document.hidden) {
292 if (document.hidden) {
289 if (documentOriginalTitle === '') {
293 if (documentOriginalTitle === '') {
290 documentOriginalTitle = document.title;
294 documentOriginalTitle = document.title;
291 }
295 }
292 unreadPosts = unreadPosts + newPostCount;
296 unreadPosts = unreadPosts + newPostCount;
293
297
294 var newTitle = '* ';
298 var newTitle = '* ';
295 if (unreadPosts > 0) {
299 if (unreadPosts > 0) {
296 newTitle += '[' + unreadPosts + '] ';
300 newTitle += '[' + unreadPosts + '] ';
297 }
301 }
298 newTitle += documentOriginalTitle;
302 newTitle += documentOriginalTitle;
299
303
300 document.title = newTitle;
304 document.title = newTitle;
301
305
302 document.addEventListener('visibilitychange', function() {
306 document.addEventListener('visibilitychange', function() {
303 if (documentOriginalTitle !== '') {
307 if (documentOriginalTitle !== '') {
304 document.title = documentOriginalTitle;
308 document.title = documentOriginalTitle;
305 documentOriginalTitle = '';
309 documentOriginalTitle = '';
306 unreadPosts = 0;
310 unreadPosts = 0;
307 }
311 }
308
312
309 document.removeEventListener('visibilitychange', null);
313 document.removeEventListener('visibilitychange', null);
310 });
314 });
311 }
315 }
312 }
316 }
313
317
314 /**
318 /**
315 * Clear all entered values in the form fields
319 * Clear all entered values in the form fields
316 */
320 */
317 function resetForm(form) {
321 function resetForm(form) {
318 form.find('input:text, input:password, input:file, select, textarea').val('');
322 form.find('input:text, input:password, input:file, select, textarea').val('');
319 form.find('input:radio, input:checkbox')
323 form.find('input:radio, input:checkbox')
320 .removeAttr('checked').removeAttr('selected');
324 .removeAttr('checked').removeAttr('selected');
321 $('.file_wrap').find('.file-thumb').remove();
325 $('.file_wrap').find('.file-thumb').remove();
322 $('#preview-text').hide();
326 $('#preview-text').hide();
323 }
327 }
324
328
325 /**
329 /**
326 * When the form is posted, this method will be run as a callback
330 * When the form is posted, this method will be run as a callback
327 */
331 */
328 function updateOnPost(response, statusText, xhr, form) {
332 function updateOnPost(response, statusText, xhr, form) {
329 var json = $.parseJSON(response);
333 var json = $.parseJSON(response);
330 var status = json.status;
334 var status = json.status;
331
335
332 showAsErrors(form, '');
336 showAsErrors(form, '');
333
337
334 if (status === 'ok') {
338 if (status === 'ok') {
335 resetFormPosition();
339 resetFormPosition();
336 resetForm(form);
340 resetForm(form);
337 getThreadDiff();
341 getThreadDiff();
338 scrollToBottom();
342 scrollToBottom();
339 } else {
343 } else {
340 var errors = json.errors;
344 var errors = json.errors;
341 for (var i = 0; i < errors.length; i++) {
345 for (var i = 0; i < errors.length; i++) {
342 var fieldErrors = errors[i];
346 var fieldErrors = errors[i];
343
347
344 var error = fieldErrors.errors;
348 var error = fieldErrors.errors;
345
349
346 showAsErrors(form, error);
350 showAsErrors(form, error);
347 }
351 }
348 }
352 }
349 }
353 }
350
354
351 /**
355 /**
352 * Show text in the errors row of the form.
356 * Show text in the errors row of the form.
353 * @param form
357 * @param form
354 * @param text
358 * @param text
355 */
359 */
356 function showAsErrors(form, text) {
360 function showAsErrors(form, text) {
357 form.children('.form-errors').remove();
361 form.children('.form-errors').remove();
358
362
359 if (text.length > 0) {
363 if (text.length > 0) {
360 var errorList = $('<div class="form-errors">' + text + '<div>');
364 var errorList = $('<div class="form-errors">' + text + '<div>');
361 errorList.appendTo(form);
365 errorList.appendTo(form);
362 }
366 }
363 }
367 }
364
368
365 /**
369 /**
366 * Run js methods that are usually run on the document, on the new post
370 * Run js methods that are usually run on the document, on the new post
367 */
371 */
368 function processNewPost(post) {
372 function processNewPost(post) {
369 addRefLinkPreview(post[0]);
373 addRefLinkPreview(post[0]);
370 highlightCode(post);
374 highlightCode(post);
371 blink(post);
375 blink(post);
372 }
376 }
373
377
374 function replacePartial(oldNode, newNode, recursive) {
378 function replacePartial(oldNode, newNode, recursive) {
375 if (!equalNodes(oldNode, newNode)) {
379 if (!equalNodes(oldNode, newNode)) {
376 // Update parent node attributes
380 // Update parent node attributes
377 updateNodeAttr(oldNode, newNode, ATTR_CLASS);
381 updateNodeAttr(oldNode, newNode, ATTR_CLASS);
378 updateNodeAttr(oldNode, newNode, ATTR_UID);
382 updateNodeAttr(oldNode, newNode, ATTR_UID);
379
383
380 // Replace children
384 // Replace children
381 var children = oldNode.children();
385 var children = oldNode.children();
382 if (children.length == 0) {
386 if (children.length == 0) {
383 console.log(oldContent);
384 console.log(newContent)
385
386 oldNode.replaceWith(newNode);
387 oldNode.replaceWith(newNode);
387 } else {
388 } else {
388 var newChildren = newNode.children();
389 var newChildren = newNode.children();
389 newChildren.each(function(i) {
390 newChildren.each(function(i) {
390 var newChild = newChildren.eq(i);
391 var newChild = newChildren.eq(i);
391 var newChildClass = newChild.attr(ATTR_CLASS);
392 var newChildClass = newChild.attr(ATTR_CLASS);
392
393
393 // Update only certain allowed blocks (e.g. not images)
394 // Update only certain allowed blocks (e.g. not images)
394 if (ALLOWED_FOR_PARTIAL_UPDATE.indexOf(newChildClass) > -1) {
395 if (ALLOWED_FOR_PARTIAL_UPDATE.indexOf(newChildClass) > -1) {
395 var oldChild = oldNode.children('.' + newChildClass);
396 var oldChild = oldNode.children('.' + newChildClass);
396
397
397 if (oldChild.length == 0) {
398 if (oldChild.length == 0) {
398 oldNode.append(newChild);
399 oldNode.append(newChild);
399 } else {
400 } else {
400 if (!equalNodes(oldChild, newChild)) {
401 if (!equalNodes(oldChild, newChild)) {
401 if (recursive) {
402 if (recursive) {
402 replacePartial(oldChild, newChild, false);
403 replacePartial(oldChild, newChild, false);
403 } else {
404 } else {
404 oldChild.replaceWith(newChild);
405 oldChild.replaceWith(newChild);
405 }
406 }
406 }
407 }
407 }
408 }
408 }
409 }
409 });
410 });
410 }
411 }
411 }
412 }
412 }
413 }
413
414
414 /**
415 /**
415 * Compare nodes by content
416 * Compare nodes by content
416 */
417 */
417 function equalNodes(node1, node2) {
418 function equalNodes(node1, node2) {
418 return node1[0].outerHTML == node2[0].outerHTML;
419 return node1[0].outerHTML == node2[0].outerHTML;
419 }
420 }
420
421
421 /**
422 /**
422 * Update attribute of a node if it has changed
423 * Update attribute of a node if it has changed
423 */
424 */
424 function updateNodeAttr(oldNode, newNode, attrName) {
425 function updateNodeAttr(oldNode, newNode, attrName) {
425 var oldAttr = oldNode.attr(attrName);
426 var oldAttr = oldNode.attr(attrName);
426 var newAttr = newNode.attr(attrName);
427 var newAttr = newNode.attr(attrName);
427 if (oldAttr != newAttr) {
428 if (oldAttr != newAttr) {
428 oldNode.attr(attrName, newAttr);
429 oldNode.attr(attrName, newAttr);
429 };
430 }
430 }
431 }
431
432
432 $(document).ready(function(){
433 $(document).ready(function(){
433 if (initAutoupdate()) {
434 if (initAutoupdate()) {
434 // Post form data over AJAX
435 // Post form data over AJAX
435 var threadId = $('div.thread').children('.post').first().attr('id');
436 var threadId = $('div.thread').children('.post').first().attr('id');
436
437
437 var form = $('#form');
438 var form = $('#form');
438
439
439 if (form.length > 0) {
440 if (form.length > 0) {
440 var options = {
441 var options = {
441 beforeSubmit: function(arr, $form, options) {
442 beforeSubmit: function(arr, $form, options) {
442 showAsErrors($('form'), gettext('Sending message...'));
443 showAsErrors($('#form'), gettext('Sending message...'));
443 },
444 },
444 success: updateOnPost,
445 success: updateOnPost,
445 error: function() {
446 error: function() {
446 showAsErrors($('form'), gettext('Server error!'));
447 showAsErrors($('#form'), gettext('Server error!'));
447 },
448 },
448 url: '/api/add_post/' + threadId + '/'
449 url: '/api/add_post/' + threadId + '/'
449 };
450 };
450
451
451 form.ajaxForm(options);
452 form.ajaxForm(options);
452
453
453 resetForm(form);
454 resetForm(form);
454 }
455 }
455 }
456 }
456 });
457 });
@@ -1,186 +1,187 b''
1 {% extends "boards/base.html" %}
1 {% extends "boards/base.html" %}
2
2
3 {% load i18n %}
3 {% load i18n %}
4 {% load board %}
4 {% load board %}
5 {% load static %}
5 {% load static %}
6 {% load tz %}
6 {% load tz %}
7
7
8 {% block head %}
8 {% block head %}
9 <meta name="robots" content="noindex">
9 <meta name="robots" content="noindex">
10
10
11 {% if tag %}
11 {% if tag %}
12 <title>{{ tag.name }} - {{ site_name }}</title>
12 <title>{{ tag.name }} - {{ site_name }}</title>
13 {% else %}
13 {% else %}
14 <title>{{ site_name }}</title>
14 <title>{{ site_name }}</title>
15 {% endif %}
15 {% endif %}
16
16
17 {% if prev_page_link %}
17 {% if prev_page_link %}
18 <link rel="prev" href="{{ prev_page_link }}" />
18 <link rel="prev" href="{{ prev_page_link }}" />
19 {% endif %}
19 {% endif %}
20 {% if next_page_link %}
20 {% if next_page_link %}
21 <link rel="next" href="{{ next_page_link }}" />
21 <link rel="next" href="{{ next_page_link }}" />
22 {% endif %}
22 {% endif %}
23
23
24 {% endblock %}
24 {% endblock %}
25
25
26 {% block content %}
26 {% block content %}
27
27
28 {% get_current_language as LANGUAGE_CODE %}
28 {% get_current_language as LANGUAGE_CODE %}
29 {% get_current_timezone as TIME_ZONE %}
29 {% get_current_timezone as TIME_ZONE %}
30
30
31 {% for banner in banners %}
31 {% for banner in banners %}
32 <div class="post">
32 <div class="post">
33 <div class="title">{{ banner.title }}</div>
33 <div class="title">{{ banner.title }}</div>
34 <div>{{ banner.text }}</div>
34 <div>{{ banner.text }}</div>
35 <div>{% trans 'Related message' %}: <a href="{{ banner.post.get_absolute_url }}">>>{{ banner.post.id }}</a></div>
35 <div>{% trans 'Related message' %}: <a href="{{ banner.post.get_absolute_url }}">>>{{ banner.post.id }}</a></div>
36 </div>
36 </div>
37 {% endfor %}
37 {% endfor %}
38
38
39 {% if tag %}
39 {% if tag %}
40 <div class="tag_info">
40 <div class="tag_info" style="border-bottom: solid .5ex #{{ tag.get_color }}">
41 {% if random_image_post %}
41 {% if random_image_post %}
42 <div class="tag-image">
42 <div class="tag-image">
43 {% with image=random_image_post.images.first %}
43 {% with image=random_image_post.images.first %}
44 <a href="{{ random_image_post.get_absolute_url }}"><img
44 <a href="{{ random_image_post.get_absolute_url }}"><img
45 src="{{ image.image.url_200x150 }}"
45 src="{{ image.image.url_200x150 }}"
46 width="{{ image.pre_width }}"
46 width="{{ image.pre_width }}"
47 height="{{ image.pre_height }}"/></a>
47 height="{{ image.pre_height }}"/></a>
48 {% endwith %}
48 {% endwith %}
49 </div>
49 </div>
50 {% endif %}
50 {% endif %}
51 <div class="tag-text-data">
51 <div class="tag-text-data">
52 <h2>
52 <h2>
53 <form action="{% url 'tag' tag.name %}" method="post" class="post-button-form">
53 <form action="{% url 'tag' tag.name %}" method="post" class="post-button-form">
54 {% if is_favorite %}
54 {% if is_favorite %}
55 <button name="method" value="unsubscribe" class="fav">β˜…</button>
55 <button name="method" value="unsubscribe" class="fav">β˜…</button>
56 {% else %}
56 {% else %}
57 <button name="method" value="subscribe" class="not_fav">β˜…</button>
57 <button name="method" value="subscribe" class="not_fav">β˜…</button>
58 {% endif %}
58 {% endif %}
59 </form>
59 </form>
60 <form action="{% url 'tag' tag.name %}" method="post" class="post-button-form">
60 <form action="{% url 'tag' tag.name %}" method="post" class="post-button-form">
61 {% if is_hidden %}
61 {% if is_hidden %}
62 <button name="method" value="unhide" class="fav">H</button>
62 <button name="method" value="unhide" class="fav">H</button>
63 {% else %}
63 {% else %}
64 <button name="method" value="hide" class="not_fav">H</button>
64 <button name="method" value="hide" class="not_fav">H</button>
65 {% endif %}
65 {% endif %}
66 </form>
66 </form>
67 {{ tag.get_view|safe }}
67 {{ tag.get_view|safe }}
68 {% if moderator %}
68 {% if moderator %}
69 <span class="moderator_info">| <a href="{% url 'admin:boards_tag_change' tag.id %}">{% trans 'Edit tag' %}</a></span>
69 <span class="moderator_info">| <a href="{% url 'admin:boards_tag_change' tag.id %}">{% trans 'Edit tag' %}</a></span>
70 {% endif %}
70 {% endif %}
71 </h2>
71 </h2>
72 {% if tag.get_description %}
72 {% if tag.get_description %}
73 <p>{{ tag.get_description|safe }}</p>
73 <p>{{ tag.get_description|safe }}</p>
74 {% endif %}
74 {% endif %}
75 <p>{% blocktrans with active_thread_count=tag.get_active_thread_count thread_count=tag.get_thread_count post_count=tag.get_post_count %}This tag has {{ thread_count }} threads ({{ active_thread_count}} active) and {{ post_count }} posts.{% endblocktrans %}</p>
75 <p>{% blocktrans with active_thread_count=tag.get_active_thread_count thread_count=tag.get_thread_count post_count=tag.get_post_count %}This tag has {{ thread_count }} threads ({{ active_thread_count}} active) and {{ post_count }} posts.{% endblocktrans %}</p>
76 {% if related_tags %}
76 {% if tag.get_parent %}
77 <p>{% trans 'Related tags:' %}
77 <p>
78 {% for rel_tag in related_tags %}
78 {% if tag.get_parent %}
79 {{ rel_tag.get_view|safe }}{% if not forloop.last %}, {% else %}.{% endif %}
79 {{ tag.get_parent.get_view|safe }} /
80 {% endfor %}
80 {% endif %}
81 {{ tag.get_view|safe }}
81 </p>
82 </p>
82 {% endif %}
83 {% endif %}
83 </div>
84 </div>
84 </div>
85 </div>
85 {% endif %}
86 {% endif %}
86
87
87 {% if threads %}
88 {% if threads %}
88 {% if prev_page_link %}
89 {% if prev_page_link %}
89 <div class="page_link">
90 <div class="page_link">
90 <a href="{{ prev_page_link }}">{% trans "Previous page" %}</a>
91 <a href="{{ prev_page_link }}">{% trans "Previous page" %}</a>
91 </div>
92 </div>
92 {% endif %}
93 {% endif %}
93
94
94 {% for thread in threads %}
95 {% for thread in threads %}
95 <div class="thread">
96 <div class="thread">
96 {% post_view thread.get_opening_post moderator=moderator is_opening=True thread=thread truncated=True need_open_link=True %}
97 {% post_view thread.get_opening_post moderator=moderator thread=thread truncated=True need_open_link=True %}
97 {% if not thread.archived %}
98 {% if not thread.archived %}
98 {% with last_replies=thread.get_last_replies %}
99 {% with last_replies=thread.get_last_replies %}
99 {% if last_replies %}
100 {% if last_replies %}
100 {% with skipped_replies_count=thread.get_skipped_replies_count %}
101 {% with skipped_replies_count=thread.get_skipped_replies_count %}
101 {% if skipped_replies_count %}
102 {% if skipped_replies_count %}
102 <div class="skipped_replies">
103 <div class="skipped_replies">
103 <a href="{% url 'thread' thread.get_opening_post_id %}">
104 <a href="{% url 'thread' thread.get_opening_post_id %}">
104 {% blocktrans with count=skipped_replies_count %}Skipped {{ count }} replies. Open thread to see all replies.{% endblocktrans %}
105 {% blocktrans count count=skipped_replies_count %}Skipped {{ count }} reply. Open thread to see all replies.{% plural %}Skipped {{ count }} replies. Open thread to see all replies.{% endblocktrans %}
105 </a>
106 </a>
106 </div>
107 </div>
107 {% endif %}
108 {% endif %}
108 {% endwith %}
109 {% endwith %}
109 <div class="last-replies">
110 <div class="last-replies">
110 {% for post in last_replies %}
111 {% for post in last_replies %}
111 {% post_view post is_opening=False moderator=moderator truncated=True %}
112 {% post_view post moderator=moderator truncated=True %}
112 {% endfor %}
113 {% endfor %}
113 </div>
114 </div>
114 {% endif %}
115 {% endif %}
115 {% endwith %}
116 {% endwith %}
116 {% endif %}
117 {% endif %}
117 </div>
118 </div>
118 {% endfor %}
119 {% endfor %}
119
120
120 {% if next_page_link %}
121 {% if next_page_link %}
121 <div class="page_link">
122 <div class="page_link">
122 <a href="{{ next_page_link }}">{% trans "Next page" %}</a>
123 <a href="{{ next_page_link }}">{% trans "Next page" %}</a>
123 </div>
124 </div>
124 {% endif %}
125 {% endif %}
125 {% else %}
126 {% else %}
126 <div class="post">
127 <div class="post">
127 {% trans 'No threads exist. Create the first one!' %}</div>
128 {% trans 'No threads exist. Create the first one!' %}</div>
128 {% endif %}
129 {% endif %}
129
130
130 <div class="post-form-w">
131 <div class="post-form-w">
131 <script src="{% static 'js/panel.js' %}"></script>
132 <script src="{% static 'js/panel.js' %}"></script>
132 <div class="post-form">
133 <div class="post-form">
133 <div class="form-title">{% trans "Create new thread" %}</div>
134 <div class="form-title">{% trans "Create new thread" %}</div>
134 <div class="swappable-form-full">
135 <div class="swappable-form-full">
135 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
136 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
136 {{ form.as_div }}
137 {{ form.as_div }}
137 <div class="form-submit">
138 <div class="form-submit">
138 <input type="submit" value="{% trans "Post" %}"/>
139 <input type="submit" value="{% trans "Post" %}"/>
139 <button id="preview-button" onclick="return false;">{% trans 'Preview' %}</button>
140 <button id="preview-button" onclick="return false;">{% trans 'Preview' %}</button>
140 </div>
141 </div>
141 </form>
142 </form>
142 </div>
143 </div>
143 <div>
144 <div>
144 {% trans 'Tags must be delimited by spaces. Text or image is required.' %}
145 {% trans 'Tags must be delimited by spaces. Text or image is required.' %}
145 </div>
146 </div>
146 <div id="preview-text"></div>
147 <div id="preview-text"></div>
147 <div><a href="{% url "staticpage" name="help" %}">{% trans 'Text syntax' %}</a></div>
148 <div><a href="{% url "staticpage" name="help" %}">{% trans 'Text syntax' %}</a></div>
148 <div><a href="{% url "tags" "required" %}">{% trans 'Tags' %}</a></div>
149 <div><a href="{% url "tags" "required" %}">{% trans 'Tags' %}</a></div>
149 </div>
150 </div>
150 </div>
151 </div>
151
152
152 <script src="{% static 'js/form.js' %}"></script>
153 <script src="{% static 'js/form.js' %}"></script>
153 <script src="{% static 'js/thread_create.js' %}"></script>
154 <script src="{% static 'js/thread_create.js' %}"></script>
154
155
155 {% endblock %}
156 {% endblock %}
156
157
157 {% block metapanel %}
158 {% block metapanel %}
158
159
159 <span class="metapanel">
160 <span class="metapanel">
160 <b><a href="{% url "authors" %}">{{ site_name }}</a> {{ version }}</b>
161 <b><a href="{% url "authors" %}">{{ site_name }}</a> {{ version }}</b>
161 {% trans "Pages:" %}
162 {% trans "Pages:" %}
162 [
163 [
163 {% with dividers=paginator.get_dividers %}
164 {% with dividers=paginator.get_dividers %}
164 {% for page in paginator.get_divided_range %}
165 {% for page in paginator.get_divided_range %}
165 {% if page in dividers %}
166 {% if page in dividers %}
166 …,
167 …,
167 {% endif %}
168 {% endif %}
168 <a
169 <a
169 {% ifequal page current_page.number %}
170 {% ifequal page current_page.number %}
170 class="current_page"
171 class="current_page"
171 {% endifequal %}
172 {% endifequal %}
172 href="
173 href="
173 {% if tag %}
174 {% if tag %}
174 {% url "tag" tag_name=tag.name %}?page={{ page }}
175 {% url "tag" tag_name=tag.name %}?page={{ page }}
175 {% else %}
176 {% else %}
176 {% url "index" %}?page={{ page }}
177 {% url "index" %}?page={{ page }}
177 {% endif %}
178 {% endif %}
178 ">{{ page }}</a>
179 ">{{ page }}</a>
179 {% if not forloop.last %},{% endif %}
180 {% if not forloop.last %},{% endif %}
180 {% endfor %}
181 {% endfor %}
181 {% endwith %}
182 {% endwith %}
182 ]
183 ]
183 [<a href="rss/">RSS</a>]
184 [<a href="rss/">RSS</a>]
184 </span>
185 </span>
185
186
186 {% endblock %}
187 {% endblock %}
@@ -1,74 +1,79 b''
1 {% load staticfiles %}
1 {% load staticfiles %}
2 {% load i18n %}
2 {% load i18n %}
3 {% load l10n %}
3 {% load l10n %}
4 {% load static from staticfiles %}
4 {% load static from staticfiles %}
5
5
6 <!DOCTYPE html>
6 <!DOCTYPE html>
7 <html>
7 <html>
8 <head>
8 <head>
9 <link rel="stylesheet" type="text/css" href="{% static 'css/base.css' %}" media="all"/>
9 <link rel="stylesheet" type="text/css" href="{% static 'css/base.css' %}" media="all"/>
10 <link rel="stylesheet" type="text/css" href="{% static 'css/3party/highlight.css' %}" media="all"/>
10 <link rel="stylesheet" type="text/css" href="{% static 'css/3party/highlight.css' %}" media="all"/>
11 <link rel="stylesheet" type="text/css" href="{% static 'css/3party/jquery-ui.min.css' %}" media="all"/>
11 <link rel="stylesheet" type="text/css" href="{% static 'css/3party/jquery-ui.min.css' %}" media="all"/>
12 <link rel="stylesheet" type="text/css" href="{% static theme_css %}" media="all"/>
12 <link rel="stylesheet" type="text/css" href="{% static theme_css %}" media="all"/>
13
13
14 <link rel="alternate" type="application/rss+xml" href="rss/" title="{% trans 'Feed' %}"/>
14 <link rel="alternate" type="application/rss+xml" href="rss/" title="{% trans 'Feed' %}"/>
15
15
16 <link rel="icon" type="image/png"
16 <link rel="icon" type="image/png"
17 href="{% static 'favicon.png' %}">
17 href="{% static 'favicon.png' %}">
18
18
19 <meta name="viewport" content="width=device-width, initial-scale=1"/>
19 <meta name="viewport" content="width=device-width, initial-scale=1"/>
20 <meta charset="utf-8"/>
20 <meta charset="utf-8"/>
21
21
22 {% block head %}{% endblock %}
22 {% block head %}{% endblock %}
23 </head>
23 </head>
24 <body data-image-viewer="{{ image_viewer }}">
24 <body data-image-viewer="{{ image_viewer }}">
25 <script src="{% static 'js/jquery-2.0.1.min.js' %}"></script>
25 <script src="{% static 'js/jquery-2.0.1.min.js' %}"></script>
26 <script src="{% static 'js/3party/jquery-ui.min.js' %}"></script>
26 <script src="{% static 'js/3party/jquery-ui.min.js' %}"></script>
27 <script src="{% static 'js/jquery.mousewheel.js' %}"></script>
27 <script src="{% static 'js/jquery.mousewheel.js' %}"></script>
28 <script src="{% url 'js_info_dict' %}"></script>
28 <script src="{% url 'js_info_dict' %}"></script>
29
29
30 <div class="navigation_panel header">
30 <div class="navigation_panel header">
31 <a class="link" href="{% url 'index' %}">{% trans "All threads" %}</a>
31 <a class="link" href="{% url 'index' %}">{% trans "All threads" %}</a>
32 {% if tags_str %}
32 {% if tags_str %}
33 {% autoescape off %}
33 {% autoescape off %}
34 {{ tags_str }},
34 {{ tags_str }},
35 {% endautoescape %}
35 {% endautoescape %}
36 {% else %}
36 {% else %}
37 {% trans 'Add tags' %} β†’
37 {% trans 'Add tags' %} β†’
38 {% endif %}
38 {% endif %}
39 <a href="{% url 'tags' 'required'%}" title="{% trans 'Tag management' %}">{% trans "tags" %}</a>,
39 <a href="{% url 'tags' 'required'%}" title="{% trans 'Tag management' %}">{% trans "tags" %}</a>,
40 <a href="{% url 'search' %}" title="{% trans 'Search' %}">{% trans 'search' %}</a>,
40 <a href="{% url 'search' %}" title="{% trans 'Search' %}">{% trans 'search' %}</a>,
41 <a href="{% url 'feed' %}" title="{% trans 'Feed' %}">{% trans 'feed' %}</a>,
41 <a href="{% url 'feed' %}" title="{% trans 'Feed' %}">{% trans 'feed' %}</a>,
42 <a href="{% url 'random' %}" title="{% trans 'Random images' %}">{% trans 'random' %}</a>
42 <a href="{% url 'random' %}" title="{% trans 'Random images' %}">{% trans 'random' %}</a>{% if has_fav_threads %},
43
44 <a href="#" id="fav-panel-btn">{% trans 'favorites' %} <span id="new-fav-post-count"></span></a>
45 {% endif %}
43
46
44 {% if username %}
47 {% if username %}
45 <a class="right-link link" href="{% url 'notifications' username %}" title="{% trans 'Notifications' %}">
48 <a class="right-link link" href="{% url 'notifications' username %}" title="{% trans 'Notifications' %}">
46 {% trans 'Notifications' %}
49 {% trans 'Notifications' %}
47 {% ifnotequal new_notifications_count 0 %}
50 {% ifnotequal new_notifications_count 0 %}
48 (<b>{{ new_notifications_count }}</b>)
51 (<b>{{ new_notifications_count }}</b>)
49 {% endifnotequal %}
52 {% endifnotequal %}
50 </a>
53 </a>
51 {% endif %}
54 {% endif %}
52
55
53 <a class="right-link link" href="{% url 'settings' %}">{% trans 'Settings' %}</a>
56 <a class="right-link link" href="{% url 'settings' %}">{% trans 'Settings' %}</a>
54 </div>
57 </div>
55
58
59 <div id="fav-panel"><div class="post">{% trans "Loading..." %}</div></div>
60
56 {% block content %}{% endblock %}
61 {% block content %}{% endblock %}
57
62
58 <script src="{% static 'js/3party/highlight.min.js' %}"></script>
63 <script src="{% static 'js/3party/highlight.min.js' %}"></script>
59 <script src="{% static 'js/popup.js' %}"></script>
64 <script src="{% static 'js/popup.js' %}"></script>
60 <script src="{% static 'js/image.js' %}"></script>
65 <script src="{% static 'js/image.js' %}"></script>
61 <script src="{% static 'js/refpopup.js' %}"></script>
66 <script src="{% static 'js/refpopup.js' %}"></script>
62 <script src="{% static 'js/main.js' %}"></script>
67 <script src="{% static 'js/main.js' %}"></script>
63
68
64 <div class="navigation_panel footer">
69 <div class="navigation_panel footer">
65 {% block metapanel %}{% endblock %}
70 {% block metapanel %}{% endblock %}
66 [<a href="{% url 'admin:index' %}">{% trans 'Admin' %}</a>]
71 [<a href="{% url 'admin:index' %}">{% trans 'Admin' %}</a>]
67 {% with ppd=posts_per_day|floatformat:2 %}
72 {% with ppd=posts_per_day|floatformat:2 %}
68 {% blocktrans %}Speed: {{ ppd }} posts per day{% endblocktrans %}
73 {% blocktrans %}Speed: {{ ppd }} posts per day{% endblocktrans %}
69 {% endwith %}
74 {% endwith %}
70 <a class="link" href="#top" id="up">{% trans 'Up' %}</a>
75 <a class="link" href="#top" id="up">{% trans 'Up' %}</a>
71 </div>
76 </div>
72
77
73 </body>
78 </body>
74 </html>
79 </html>
@@ -1,113 +1,109 b''
1 {% load i18n %}
1 {% load i18n %}
2 {% load board %}
2 {% load board %}
3
3
4 {% get_current_language as LANGUAGE_CODE %}
4 {% get_current_language as LANGUAGE_CODE %}
5
5
6 <div class="{{ css_class }}" id="{{ post.id }}" data-uid="{{ post.uid }}">
6 <div class="{{ css_class }}" id="{{ post.id }}" data-uid="{{ post.uid }}">
7 <div class="post-info">
7 <div class="post-info">
8 <a class="post_id" href="{{ post.get_absolute_url }}">#{{ post.get_absolute_id }}</a>
8 <a class="post_id" href="{{ post.get_absolute_url }}">#{{ post.get_absolute_id }}</a>
9 <span class="title">{{ post.title }}</span>
9 <span class="title">{{ post.title }}</span>
10 <span class="pub_time"><time datetime="{{ post.pub_time|date:'c' }}">{{ post.pub_time }}</time></span>
10 <span class="pub_time"><time datetime="{{ post.pub_time|date:'c' }}">{{ post.pub_time }}</time></span>
11 {% if post.tripcode %}
11 {% if post.tripcode %}
12 {% with tripcode=post.get_tripcode %}
12 {% with tripcode=post.get_tripcode %}
13 <a href="{% url 'feed' %}?tripcode={{ tripcode.get_full_text }}"
13 <a href="{% url 'feed' %}?tripcode={{ tripcode.get_full_text }}"
14 class="tripcode" title="{{ tripcode.get_full_text }}"
14 class="tripcode" title="{{ tripcode.get_full_text }}"
15 style="border: solid 2px #{{ tripcode.get_color }}; border-left: solid 1ex #{{ tripcode.get_color }};">{{ tripcode.get_short_text }}</a>
15 style="border: solid 2px #{{ tripcode.get_color }}; border-left: solid 1ex #{{ tripcode.get_color }};">{{ tripcode.get_short_text }}</a>
16 {% endwith %}
16 {% endwith %}
17 {% endif %}
17 {% endif %}
18 {% comment %}
18 {% comment %}
19 Thread death time needs to be shown only if the thread is alredy archived
19 Thread death time needs to be shown only if the thread is alredy archived
20 and this is an opening post (thread death time) or a post for popup
20 and this is an opening post (thread death time) or a post for popup
21 (we don't see OP here so we show the death time in the post itself).
21 (we don't see OP here so we show the death time in the post itself).
22 {% endcomment %}
22 {% endcomment %}
23 {% if thread.archived %}
23 {% if thread.archived %}
24 {% if is_opening %}
24 {% if is_opening %}
25 β€” <time datetime="{{ thread.bump_time|date:'c' }}">{{ thread.bump_time }}</time>
25 β€” <time datetime="{{ thread.bump_time|date:'c' }}">{{ thread.bump_time }}</time>
26 {% endif %}
26 {% endif %}
27 {% endif %}
27 {% endif %}
28 {% if is_opening %}
28 {% if is_opening %}
29 {% if need_open_link %}
29 {% if need_open_link %}
30 {% if thread.archived %}
30 {% if thread.archived %}
31 <a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>
31 <a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>
32 {% else %}
32 {% else %}
33 <a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>
33 <a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>
34 {% endif %}
34 {% endif %}
35 {% endif %}
35 {% endif %}
36 {% else %}
36 {% else %}
37 {% if need_op_data %}
37 {% if need_op_data %}
38 {% with thread.get_opening_post as op %}
38 {% with thread.get_opening_post as op %}
39 {% trans " in " %}{{ op.get_link_view|safe }} <span class="title">{{ op.get_title|striptags|truncatewords:5 }}</span>
39 {% trans " in " %}{{ op.get_link_view|safe }} <span class="title">{{ op.get_title_or_text }}</span>
40 {% endwith %}
40 {% endwith %}
41 {% endif %}
41 {% endif %}
42 {% endif %}
42 {% endif %}
43 {% if reply_link and not thread.archived %}
43 {% if reply_link and not thread.archived %}
44 <a href="#form" onclick="addQuickReply('{{ post.id }}'); return false;">{% trans 'Reply' %}</a>
44 <a href="#form" onclick="addQuickReply('{{ post.id }}'); return false;">{% trans 'Reply' %}</a>
45 {% endif %}
45 {% endif %}
46
46
47 {% if post.global_id %}
47 {% if post.global_id %}
48 <a class="global-id" href="{% url 'post_sync_data' post.id %}"> [RAW] </a>
48 <a class="global-id" href="{% url 'post_sync_data' post.id %}"> [RAW] </a>
49 {% endif %}
49 {% endif %}
50
50
51 {% if moderator %}
51 {% if moderator %}
52 <span class="moderator_info">
52 <span class="moderator_info">
53 | <a href="{% url 'admin:boards_post_change' post.id %}">{% trans 'Edit' %}</a>
53 | <a href="{% url 'admin:boards_post_change' post.id %}">{% trans 'Edit' %}</a>
54 {% if is_opening %}
54 {% if is_opening %}
55 | <a href="{% url 'admin:boards_thread_change' thread.id %}">{% trans 'Edit thread' %}</a>
55 | <a href="{% url 'admin:boards_thread_change' thread.id %}">{% trans 'Edit thread' %}</a>
56 {% endif %}
56 {% endif %}
57 </span>
57 </span>
58 {% endif %}
58 {% endif %}
59 </div>
59 </div>
60 {% comment %}
60 {% comment %}
61 Post images. Currently only 1 image can be posted and shown, but post model
61 Post images. Currently only 1 image can be posted and shown, but post model
62 supports multiple.
62 supports multiple.
63 {% endcomment %}
63 {% endcomment %}
64 {% if post.images.exists %}
64 {% for image in post.images.all %}
65 {% with post.images.first as image %}
66 {{ image.get_view|safe }}
65 {{ image.get_view|safe }}
67 {% endwith %}
66 {% endfor %}
68 {% endif %}
67 {% for file in post.attachments.all %}
69 {% if post.attachments.exists %}
70 {% with post.attachments.first as file %}
71 {{ file.get_view|safe }}
68 {{ file.get_view|safe }}
72 {% endwith %}
69 {% endfor %}
73 {% endif %}
74 {% comment %}
70 {% comment %}
75 Post message (text)
71 Post message (text)
76 {% endcomment %}
72 {% endcomment %}
77 <div class="message">
73 <div class="message">
78 {% autoescape off %}
74 {% autoescape off %}
79 {% if truncated %}
75 {% if truncated %}
80 {{ post.get_text|truncatewords_html:50 }}
76 {{ post.get_text|truncatewords_html:50 }}
81 {% else %}
77 {% else %}
82 {{ post.get_text }}
78 {{ post.get_text }}
83 {% endif %}
79 {% endif %}
84 {% endautoescape %}
80 {% endautoescape %}
85 </div>
81 </div>
86 {% if post.is_referenced %}
82 {% if post.is_referenced %}
87 {% if mode_tree %}
83 {% if mode_tree %}
88 <div class="tree_reply">
84 <div class="tree_reply">
89 {% for refpost in post.get_referenced_posts %}
85 {% for refpost in post.get_referenced_posts %}
90 {% post_view refpost mode_tree=True %}
86 {% post_view refpost mode_tree=True %}
91 {% endfor %}
87 {% endfor %}
92 </div>
88 </div>
93 {% else %}
89 {% else %}
94 <div class="refmap">
90 <div class="refmap">
95 {% trans "Replies" %}: {{ post.refmap|safe }}
91 {% trans "Replies" %}: {{ post.refmap|safe }}
96 </div>
92 </div>
97 {% endif %}
93 {% endif %}
98 {% endif %}
94 {% endif %}
99 {% comment %}
95 {% comment %}
100 Thread metadata: counters, tags etc
96 Thread metadata: counters, tags etc
101 {% endcomment %}
97 {% endcomment %}
102 {% if is_opening %}
98 {% if is_opening %}
103 <div class="metadata">
99 <div class="metadata">
104 {% if is_opening and need_open_link %}
100 {% if is_opening and need_open_link %}
105 {{ thread.get_reply_count }} {% trans 'messages' %},
101 {% blocktrans count count=thread.get_reply_count %}{{ count }} message{% plural %}{{ count }} messages{% endblocktrans %},
106 {{ thread.get_images_count }} {% trans 'images' %}.
102 {% blocktrans count count=thread.get_images_count %}{{ count }} image{% plural %}{{ count }} images{% endblocktrans %}.
107 {% endif %}
103 {% endif %}
108 <span class="tags">
104 <span class="tags">
109 {{ thread.get_tag_url_list|safe }}
105 {{ thread.get_tag_url_list|safe }}
110 </span>
106 </span>
111 </div>
107 </div>
112 {% endif %}
108 {% endif %}
113 </div>
109 </div>
@@ -1,40 +1,44 b''
1 {% extends "boards/base.html" %}
1 {% extends "boards/base.html" %}
2
2
3 {% load i18n %}
3 {% load i18n %}
4 {% load static from staticfiles %}
4 {% load static from staticfiles %}
5 {% load board %}
5 {% load board %}
6 {% load tz %}
6 {% load tz %}
7
7
8 {% block head %}
8 {% block head %}
9 <title>{{ opening_post.get_title|striptags|truncatewords:10 }}
9 <title>{{ opening_post.get_title_or_text }} - {{ site_name }}</title>
10 - {{ site_name }}</title>
11 {% endblock %}
10 {% endblock %}
12
11
13 {% block content %}
12 {% block content %}
14 <div class="image-mode-tab">
13 <div class="image-mode-tab">
15 <a {% ifequal mode 'normal' %}class="current_mode"{% endifequal %} href="{% url 'thread' opening_post.id %}">{% trans 'Normal' %}</a>,
14 <a {% ifequal mode 'normal' %}class="current_mode"{% endifequal %} href="{% url 'thread' opening_post.id %}">{% trans 'Normal' %}</a>,
16 <a {% ifequal mode 'gallery' %}class="current_mode"{% endifequal %} href="{% url 'thread_gallery' opening_post.id %}">{% trans 'Gallery' %}</a>,
15 <a {% ifequal mode 'gallery' %}class="current_mode"{% endifequal %} href="{% url 'thread_gallery' opening_post.id %}">{% trans 'Gallery' %}</a>,
17 <a {% ifequal mode 'tree' %}class="current_mode"{% endifequal %} href="{% url 'thread_tree' opening_post.id %}">{% trans 'Tree' %}</a>
16 <a {% ifequal mode 'tree' %}class="current_mode"{% endifequal %} href="{% url 'thread_tree' opening_post.id %}">{% trans 'Tree' %}</a>
18 </div>
17 </div>
19
18
20 {% block thread_content %}
19 {% block thread_content %}
21 {% endblock %}
20 {% endblock %}
22 {% endblock %}
21 {% endblock %}
23
22
24 {% block metapanel %}
23 {% block metapanel %}
25
24
26 <span class="metapanel"
25 <span class="metapanel"
27 data-last-update="{{ last_update }}"
26 data-last-update="{{ last_update }}"
28 data-ws-token-time="{{ ws_token_time }}"
27 data-ws-token-time="{{ ws_token_time }}"
29 data-ws-token="{{ ws_token }}"
28 data-ws-token="{{ ws_token }}"
30 data-ws-project="{{ ws_project }}"
29 data-ws-project="{{ ws_project }}"
31 data-ws-host="{{ ws_host }}"
30 data-ws-host="{{ ws_host }}"
32 data-ws-port="{{ ws_port }}">
31 data-ws-port="{{ ws_port }}">
33
32
34 <span id="reply-count">{{ thread.get_reply_count }}</span>{% if thread.has_post_limit %}/{{ thread.max_posts }}{% endif %} {% trans 'messages' %},
33 {% with replies_count=thread.get_reply_count%}
35 <span id="image-count">{{ thread.get_images_count }}</span> {% trans 'images' %}.
34 <span id="reply-count">{{ thread.get_reply_count }}</span>{% if thread.has_post_limit %}/{{ thread.max_posts }}{% endif %}
35 <span id="message-count-text">{% blocktrans count repliess_count=replies_count %}message{% plural %}messages{% endblocktrans %}</span>,
36 {% endwith %}
37 {% with images_count=thread.get_images_count%}
38 <span id="image-count">{{ images_count }}</span> <span id="image-count-text">{% blocktrans count count=images_count %}image{% plural %}images{% endblocktrans %}</span>.
39 {% endwith %}
36 {% trans 'Last update: ' %}<span id="last-update"><time datetime="{{ thread.last_edit_time|date:'c' }}">{{ thread.last_edit_time }}</time></span>
40 {% trans 'Last update: ' %}<span id="last-update"><time datetime="{{ thread.last_edit_time|date:'c' }}">{{ thread.last_edit_time }}</time></span>
37 [<a href="rss/">RSS</a>]
41 [<a href="rss/">RSS</a>]
38 </span>
42 </span>
39
43
40 {% endblock %}
44 {% endblock %}
@@ -1,57 +1,70 b''
1 {% extends "boards/thread.html" %}
1 {% extends "boards/thread.html" %}
2
2
3 {% load i18n %}
3 {% load i18n %}
4 {% load static from staticfiles %}
4 {% load static from staticfiles %}
5 {% load board %}
5 {% load board %}
6 {% load tz %}
6 {% load tz %}
7
7
8 {% block thread_content %}
8 {% block thread_content %}
9 {% get_current_language as LANGUAGE_CODE %}
9 {% get_current_language as LANGUAGE_CODE %}
10 {% get_current_timezone as TIME_ZONE %}
10 {% get_current_timezone as TIME_ZONE %}
11
11
12 <div class="tag_info">
13 <h2>
14 <form action="{% url 'thread' opening_post.id %}" method="post" class="post-button-form">
15 {% if is_favorite %}
16 <button name="method" value="unsubscribe" class="fav">β˜…</button>
17 {% else %}
18 <button name="method" value="subscribe" class="not_fav">β˜…</button>
19 {% endif %}
20 </form>
21 {{ opening_post.get_title_or_text }}
22 </h2>
23 </div>
24
12 {% if bumpable and thread.has_post_limit %}
25 {% if bumpable and thread.has_post_limit %}
13 <div class="bar-bg">
26 <div class="bar-bg">
14 <div class="bar-value" style="width:{{ bumplimit_progress }}%" id="bumplimit_progress">
27 <div class="bar-value" style="width:{{ bumplimit_progress }}%" id="bumplimit_progress">
15 </div>
28 </div>
16 <div class="bar-text">
29 <div class="bar-text">
17 <span id="left_to_limit">{{ posts_left }}</span> {% trans 'posts to bumplimit' %}
30 <span id="left_to_limit">{{ posts_left }}</span> {% trans 'posts to bumplimit' %}
18 </div>
31 </div>
19 </div>
32 </div>
20 {% endif %}
33 {% endif %}
21
34
22 <div class="thread">
35 <div class="thread">
23 {% for post in thread.get_replies %}
36 {% for post in thread.get_replies %}
24 {% post_view post moderator=moderator reply_link=True %}
37 {% post_view post moderator=moderator reply_link=True %}
25 {% endfor %}
38 {% endfor %}
26 </div>
39 </div>
27
40
28 {% if not thread.archived %}
41 {% if not thread.archived %}
29 <div class="post-form-w">
42 <div class="post-form-w">
30 <script src="{% static 'js/panel.js' %}"></script>
43 <script src="{% static 'js/panel.js' %}"></script>
31 <div class="form-title">{% trans "Reply to thread" %} #{{ opening_post.id }}<span class="reply-to-message"> {% trans "to message " %} #<span id="reply-to-message-id"></span></span></div>
44 <div class="form-title">{% trans "Reply to thread" %} #{{ opening_post.id }}<span class="reply-to-message"> {% trans "to message " %} #<span id="reply-to-message-id"></span></span></div>
32 <div class="post-form" id="compact-form">
45 <div class="post-form" id="compact-form">
33 <div class="swappable-form-full">
46 <div class="swappable-form-full">
34 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
47 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
35 <div class="compact-form-text"></div>
48 <div class="compact-form-text"></div>
36 {{ form.as_div }}
49 {{ form.as_div }}
37 <div class="form-submit">
50 <div class="form-submit">
38 <input type="submit" value="{% trans "Post" %}"/>
51 <input type="submit" value="{% trans "Post" %}"/>
39 <button id="preview-button" onclick="return false;">{% trans 'Preview' %}</button>
52 <button id="preview-button" onclick="return false;">{% trans 'Preview' %}</button>
40 </div>
53 </div>
41 </form>
54 </form>
42 </div>
55 </div>
43 <div id="preview-text"></div>
56 <div id="preview-text"></div>
44 <div><a href="{% url "staticpage" name="help" %}">
57 <div><a href="{% url "staticpage" name="help" %}">
45 {% trans 'Text syntax' %}</a></div>
58 {% trans 'Text syntax' %}</a></div>
46 <div><a id="form-close-button" href="#" onClick="resetFormPosition(); return false;">{% trans 'Close form' %}</a></div>
59 <div><a id="form-close-button" href="#" onClick="resetFormPosition(); return false;">{% trans 'Close form' %}</a></div>
47 </div>
60 </div>
48 </div>
61 </div>
49
62
50 <script src="{% static 'js/jquery.form.min.js' %}"></script>
63 <script src="{% static 'js/jquery.form.min.js' %}"></script>
51 {% endif %}
64 {% endif %}
52
65
53 <script src="{% static 'js/form.js' %}"></script>
66 <script src="{% static 'js/form.js' %}"></script>
54 <script src="{% static 'js/thread.js' %}"></script>
67 <script src="{% static 'js/thread.js' %}"></script>
55 <script src="{% static 'js/thread_update.js' %}"></script>
68 <script src="{% static 'js/thread_update.js' %}"></script>
56 <script src="{% static 'js/3party/centrifuge.js' %}"></script>
69 <script src="{% static 'js/3party/centrifuge.js' %}"></script>
57 {% endblock %}
70 {% endblock %}
@@ -1,91 +1,92 b''
1 from django.conf.urls import patterns, url
1 from django.conf.urls import patterns, url
2 from django.views.i18n import javascript_catalog
2 from django.views.i18n import javascript_catalog
3
3
4 from boards import views
4 from boards import views
5 from boards.rss import AllThreadsFeed, TagThreadsFeed, ThreadPostsFeed
5 from boards.rss import AllThreadsFeed, TagThreadsFeed, ThreadPostsFeed
6 from boards.views import api, tag_threads, all_threads, \
6 from boards.views import api, tag_threads, all_threads, \
7 settings, all_tags, feed
7 settings, all_tags, feed
8 from boards.views.authors import AuthorsView
8 from boards.views.authors import AuthorsView
9 from boards.views.notifications import NotificationView
9 from boards.views.notifications import NotificationView
10 from boards.views.search import BoardSearchView
10 from boards.views.search import BoardSearchView
11 from boards.views.static import StaticPageView
11 from boards.views.static import StaticPageView
12 from boards.views.preview import PostPreviewView
12 from boards.views.preview import PostPreviewView
13 from boards.views.sync import get_post_sync_data, response_get, response_pull
13 from boards.views.sync import get_post_sync_data, response_get, response_pull
14 from boards.views.random import RandomImageView
14 from boards.views.random import RandomImageView
15
15
16
16
17 js_info_dict = {
17 js_info_dict = {
18 'packages': ('boards',),
18 'packages': ('boards',),
19 }
19 }
20
20
21 urlpatterns = patterns('',
21 urlpatterns = patterns('',
22 # /boards/
22 # /boards/
23 url(r'^$', all_threads.AllThreadsView.as_view(), name='index'),
23 url(r'^$', all_threads.AllThreadsView.as_view(), name='index'),
24
24
25 # /boards/tag/tag_name/
25 # /boards/tag/tag_name/
26 url(r'^tag/(?P<tag_name>\w+)/$', tag_threads.TagView.as_view(),
26 url(r'^tag/(?P<tag_name>\w+)/$', tag_threads.TagView.as_view(),
27 name='tag'),
27 name='tag'),
28
28
29 # /boards/thread/
29 # /boards/thread/
30 url(r'^thread/(?P<post_id>\d+)/$', views.thread.NormalThreadView.as_view(),
30 url(r'^thread/(?P<post_id>\d+)/$', views.thread.NormalThreadView.as_view(),
31 name='thread'),
31 name='thread'),
32 url(r'^thread/(?P<post_id>\d+)/mode/gallery/$', views.thread.GalleryThreadView.as_view(),
32 url(r'^thread/(?P<post_id>\d+)/mode/gallery/$', views.thread.GalleryThreadView.as_view(),
33 name='thread_gallery'),
33 name='thread_gallery'),
34 url(r'^thread/(?P<post_id>\d+)/mode/tree/$', views.thread.TreeThreadView.as_view(),
34 url(r'^thread/(?P<post_id>\d+)/mode/tree/$', views.thread.TreeThreadView.as_view(),
35 name='thread_tree'),
35 name='thread_tree'),
36 # /feed/
36 # /feed/
37 url(r'^feed/$', views.feed.FeedView.as_view(), name='feed'),
37 url(r'^feed/$', views.feed.FeedView.as_view(), name='feed'),
38
38
39 url(r'^settings/$', settings.SettingsView.as_view(), name='settings'),
39 url(r'^settings/$', settings.SettingsView.as_view(), name='settings'),
40 url(r'^tags/(?P<query>\w+)?/?$', all_tags.AllTagsView.as_view(), name='tags'),
40 url(r'^tags/(?P<query>\w+)?/?$', all_tags.AllTagsView.as_view(), name='tags'),
41 url(r'^authors/$', AuthorsView.as_view(), name='authors'),
41 url(r'^authors/$', AuthorsView.as_view(), name='authors'),
42
42
43 url(r'^banned/$', views.banned.BannedView.as_view(), name='banned'),
43 url(r'^banned/$', views.banned.BannedView.as_view(), name='banned'),
44 url(r'^staticpage/(?P<name>\w+)/$', StaticPageView.as_view(),
44 url(r'^staticpage/(?P<name>\w+)/$', StaticPageView.as_view(),
45 name='staticpage'),
45 name='staticpage'),
46
46
47 url(r'^random/$', RandomImageView.as_view(), name='random'),
47 url(r'^random/$', RandomImageView.as_view(), name='random'),
48
48
49 # RSS feeds
49 # RSS feeds
50 url(r'^rss/$', AllThreadsFeed()),
50 url(r'^rss/$', AllThreadsFeed()),
51 url(r'^page/(?P<page>\d+)/rss/$', AllThreadsFeed()),
51 url(r'^page/(?P<page>\d+)/rss/$', AllThreadsFeed()),
52 url(r'^tag/(?P<tag_name>\w+)/rss/$', TagThreadsFeed()),
52 url(r'^tag/(?P<tag_name>\w+)/rss/$', TagThreadsFeed()),
53 url(r'^tag/(?P<tag_name>\w+)/page/(?P<page>\w+)/rss/$', TagThreadsFeed()),
53 url(r'^tag/(?P<tag_name>\w+)/page/(?P<page>\w+)/rss/$', TagThreadsFeed()),
54 url(r'^thread/(?P<post_id>\d+)/rss/$', ThreadPostsFeed()),
54 url(r'^thread/(?P<post_id>\d+)/rss/$', ThreadPostsFeed()),
55
55
56 # i18n
56 # i18n
57 url(r'^jsi18n/$', javascript_catalog, js_info_dict,
57 url(r'^jsi18n/$', javascript_catalog, js_info_dict,
58 name='js_info_dict'),
58 name='js_info_dict'),
59
59
60 # API
60 # API
61 url(r'^api/post/(?P<post_id>\d+)/$', api.get_post, name="get_post"),
61 url(r'^api/post/(?P<post_id>\d+)/$', api.get_post, name="get_post"),
62 url(r'^api/diff_thread/$', api.api_get_threaddiff, name="get_thread_diff"),
62 url(r'^api/diff_thread/$', api.api_get_threaddiff, name="get_thread_diff"),
63 url(r'^api/threads/(?P<count>\w+)/$', api.api_get_threads,
63 url(r'^api/threads/(?P<count>\w+)/$', api.api_get_threads,
64 name='get_threads'),
64 name='get_threads'),
65 url(r'^api/tags/$', api.api_get_tags, name='get_tags'),
65 url(r'^api/tags/$', api.api_get_tags, name='get_tags'),
66 url(r'^api/thread/(?P<opening_post_id>\w+)/$', api.api_get_thread_posts,
66 url(r'^api/thread/(?P<opening_post_id>\w+)/$', api.api_get_thread_posts,
67 name='get_thread'),
67 name='get_thread'),
68 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,
69 name='add_post'),
69 name='add_post'),
70 url(r'^api/notifications/(?P<username>\w+)/$', api.api_get_notifications,
70 url(r'^api/notifications/(?P<username>\w+)/$', api.api_get_notifications,
71 name='api_notifications'),
71 name='api_notifications'),
72 url(r'^api/preview/$', api.api_get_preview, name='preview'),
72 url(r'^api/preview/$', api.api_get_preview, name='preview'),
73 url(r'^api/new_posts/$', api.api_get_new_posts, name='new_posts'),
73
74
74 # Sync protocol API
75 # Sync protocol API
75 url(r'^api/sync/pull/$', response_pull, name='api_sync_pull'),
76 url(r'^api/sync/pull/$', response_pull, name='api_sync_pull'),
76 url(r'^api/sync/get/$', response_get, name='api_sync_pull'),
77 url(r'^api/sync/get/$', response_get, name='api_sync_pull'),
77 # TODO 'get' request
78 # TODO 'get' request
78
79
79 # Search
80 # Search
80 url(r'^search/$', BoardSearchView.as_view(), name='search'),
81 url(r'^search/$', BoardSearchView.as_view(), name='search'),
81
82
82 # Notifications
83 # Notifications
83 url(r'^notifications/(?P<username>\w+)$', NotificationView.as_view(), name='notifications'),
84 url(r'^notifications/(?P<username>\w+)$', NotificationView.as_view(), name='notifications'),
84
85
85 # Post preview
86 # Post preview
86 url(r'^preview/$', PostPreviewView.as_view(), name='preview'),
87 url(r'^preview/$', PostPreviewView.as_view(), name='preview'),
87
88
88 url(r'^post_xml/(?P<post_id>\d+)$', get_post_sync_data,
89 url(r'^post_xml/(?P<post_id>\d+)$', get_post_sync_data,
89 name='post_sync_data'),
90 name='post_sync_data'),
90
91
91 )
92 )
@@ -1,92 +1,103 b''
1 """
1 """
2 This module contains helper functions and helper classes.
2 This module contains helper functions and helper classes.
3 """
3 """
4 import hashlib
4 import hashlib
5 import time
5 import time
6 import hmac
6 import hmac
7
7
8 from django.core.cache import cache
8 from django.core.cache import cache
9 from django.db.models import Model
9 from django.db.models import Model
10 from django import forms
10
11
11 from django.utils import timezone
12 from django.utils import timezone
13 from django.utils.translation import ugettext_lazy as _
14 import boards
12
15
13 from neboard import settings
16 from neboard import settings
14
17
15
18
16 CACHE_KEY_DELIMITER = '_'
19 CACHE_KEY_DELIMITER = '_'
17 PERMISSION_MODERATE = 'moderation'
20 PERMISSION_MODERATE = 'moderation'
18
21
19 def get_client_ip(request):
22 def get_client_ip(request):
20 x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
23 x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
21 if x_forwarded_for:
24 if x_forwarded_for:
22 ip = x_forwarded_for.split(',')[-1].strip()
25 ip = x_forwarded_for.split(',')[-1].strip()
23 else:
26 else:
24 ip = request.META.get('REMOTE_ADDR')
27 ip = request.META.get('REMOTE_ADDR')
25 return ip
28 return ip
26
29
27
30
28 # TODO The output format is not epoch because it includes microseconds
31 # TODO The output format is not epoch because it includes microseconds
29 def datetime_to_epoch(datetime):
32 def datetime_to_epoch(datetime):
30 return int(time.mktime(timezone.localtime(
33 return int(time.mktime(timezone.localtime(
31 datetime,timezone.get_current_timezone()).timetuple())
34 datetime,timezone.get_current_timezone()).timetuple())
32 * 1000000 + datetime.microsecond)
35 * 1000000 + datetime.microsecond)
33
36
34
37
35 def get_websocket_token(user_id='', timestamp=''):
38 def get_websocket_token(user_id='', timestamp=''):
36 """
39 """
37 Create token to validate information provided by new connection.
40 Create token to validate information provided by new connection.
38 """
41 """
39
42
40 sign = hmac.new(settings.CENTRIFUGE_PROJECT_SECRET.encode())
43 sign = hmac.new(settings.CENTRIFUGE_PROJECT_SECRET.encode())
41 sign.update(settings.CENTRIFUGE_PROJECT_ID.encode())
44 sign.update(settings.CENTRIFUGE_PROJECT_ID.encode())
42 sign.update(user_id.encode())
45 sign.update(user_id.encode())
43 sign.update(timestamp.encode())
46 sign.update(timestamp.encode())
44 token = sign.hexdigest()
47 token = sign.hexdigest()
45
48
46 return token
49 return token
47
50
48
51
49 def cached_result(key_method=None):
52 def cached_result(key_method=None):
50 """
53 """
51 Caches method result in the Django's cache system, persisted by object name,
54 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.
55 object name and model id if object is a Django model.
53 """
56 """
54 def _cached_result(function):
57 def _cached_result(function):
55 def inner_func(obj, *args, **kwargs):
58 def inner_func(obj, *args, **kwargs):
56 # TODO Include method arguments to the cache key
59 # TODO Include method arguments to the cache key
57 cache_key_params = [obj.__class__.__name__, function.__name__]
60 cache_key_params = [obj.__class__.__name__, function.__name__]
58 if isinstance(obj, Model):
61 if isinstance(obj, Model):
59 cache_key_params.append(str(obj.id))
62 cache_key_params.append(str(obj.id))
60
63
61 if key_method is not None:
64 if key_method is not None:
62 cache_key_params += [str(arg) for arg in key_method(obj)]
65 cache_key_params += [str(arg) for arg in key_method(obj)]
63
66
64 cache_key = CACHE_KEY_DELIMITER.join(cache_key_params)
67 cache_key = CACHE_KEY_DELIMITER.join(cache_key_params)
65
68
66 persisted_result = cache.get(cache_key)
69 persisted_result = cache.get(cache_key)
67 if persisted_result is not None:
70 if persisted_result is not None:
68 result = persisted_result
71 result = persisted_result
69 else:
72 else:
70 result = function(obj, *args, **kwargs)
73 result = function(obj, *args, **kwargs)
71 cache.set(cache_key, result)
74 cache.set(cache_key, result)
72
75
73 return result
76 return result
74
77
75 return inner_func
78 return inner_func
76 return _cached_result
79 return _cached_result
77
80
78
81
79 def is_moderator(request):
82 def is_moderator(request):
80 try:
83 try:
81 moderate = request.user.has_perm(PERMISSION_MODERATE)
84 moderate = request.user.has_perm(PERMISSION_MODERATE)
82 except AttributeError:
85 except AttributeError:
83 moderate = False
86 moderate = False
84
87
85 return moderate
88 return moderate
86
89
87
90
88 def get_file_hash(file) -> str:
91 def get_file_hash(file) -> str:
89 md5 = hashlib.md5()
92 md5 = hashlib.md5()
90 for chunk in file.chunks():
93 for chunk in file.chunks():
91 md5.update(chunk)
94 md5.update(chunk)
92 return md5.hexdigest()
95 return md5.hexdigest()
96
97
98 def validate_file_size(size: int):
99 max_size = boards.settings.get_int('Forms', 'MaxFileSize')
100 if size > max_size:
101 raise forms.ValidationError(
102 _('File must be less than %s bytes')
103 % str(max_size))
@@ -1,170 +1,150 b''
1 from django.core.urlresolvers import reverse
1 from django.core.urlresolvers import reverse
2 from django.core.files import File
2 from django.core.files import File
3 from django.core.files.temp import NamedTemporaryFile
3 from django.core.files.temp import NamedTemporaryFile
4 from django.core.paginator import EmptyPage
4 from django.core.paginator import EmptyPage
5 from django.db import transaction
5 from django.db import transaction
6 from django.http import Http404
6 from django.http import Http404
7 from django.shortcuts import render, redirect
7 from django.shortcuts import render, redirect
8 import requests
8 import requests
9
9
10 from boards import utils, settings
10 from boards import utils, settings
11 from boards.abstracts.paginator import get_paginator
11 from boards.abstracts.paginator import get_paginator
12 from boards.abstracts.settingsmanager import get_settings_manager
12 from boards.abstracts.settingsmanager import get_settings_manager
13 from boards.forms import ThreadForm, PlainErrorList
13 from boards.forms import ThreadForm, PlainErrorList
14 from boards.models import Post, Thread, Ban, Tag, PostImage, Banner
14 from boards.models import Post, Thread, Ban, Tag, PostImage, Banner
15 from boards.views.banned import BannedView
15 from boards.views.banned import BannedView
16 from boards.views.base import BaseBoardView, CONTEXT_FORM
16 from boards.views.base import BaseBoardView, CONTEXT_FORM
17 from boards.views.posting_mixin import PostMixin
17 from boards.views.posting_mixin import PostMixin
18
18
19
19
20 FORM_TAGS = 'tags'
20 FORM_TAGS = 'tags'
21 FORM_TEXT = 'text'
21 FORM_TEXT = 'text'
22 FORM_TITLE = 'title'
22 FORM_TITLE = 'title'
23 FORM_IMAGE = 'image'
23 FORM_IMAGE = 'image'
24 FORM_THREADS = 'threads'
24 FORM_THREADS = 'threads'
25
25
26 TAG_DELIMITER = ' '
26 TAG_DELIMITER = ' '
27
27
28 PARAMETER_CURRENT_PAGE = 'current_page'
28 PARAMETER_CURRENT_PAGE = 'current_page'
29 PARAMETER_PAGINATOR = 'paginator'
29 PARAMETER_PAGINATOR = 'paginator'
30 PARAMETER_THREADS = 'threads'
30 PARAMETER_THREADS = 'threads'
31 PARAMETER_BANNERS = 'banners'
31 PARAMETER_BANNERS = 'banners'
32
32
33 PARAMETER_PREV_LINK = 'prev_page_link'
33 PARAMETER_PREV_LINK = 'prev_page_link'
34 PARAMETER_NEXT_LINK = 'next_page_link'
34 PARAMETER_NEXT_LINK = 'next_page_link'
35
35
36 TEMPLATE = 'boards/all_threads.html'
36 TEMPLATE = 'boards/all_threads.html'
37 DEFAULT_PAGE = 1
37 DEFAULT_PAGE = 1
38
38
39
39
40 class AllThreadsView(PostMixin, BaseBoardView):
40 class AllThreadsView(PostMixin, BaseBoardView):
41
41
42 def __init__(self):
42 def __init__(self):
43 self.settings_manager = None
43 self.settings_manager = None
44 super(AllThreadsView, self).__init__()
44 super(AllThreadsView, self).__init__()
45
45
46 def get(self, request, form: ThreadForm=None):
46 def get(self, request, form: ThreadForm=None):
47 page = request.GET.get('page', DEFAULT_PAGE)
47 page = request.GET.get('page', DEFAULT_PAGE)
48
48
49 params = self.get_context_data(request=request)
49 params = self.get_context_data(request=request)
50
50
51 if not form:
51 if not form:
52 form = ThreadForm(error_class=PlainErrorList)
52 form = ThreadForm(error_class=PlainErrorList)
53
53
54 self.settings_manager = get_settings_manager(request)
54 self.settings_manager = get_settings_manager(request)
55 paginator = get_paginator(self.get_threads(),
55 paginator = get_paginator(self.get_threads(),
56 settings.get_int('View', 'ThreadsPerPage'))
56 settings.get_int('View', 'ThreadsPerPage'))
57 paginator.current_page = int(page)
57 paginator.current_page = int(page)
58
58
59 try:
59 try:
60 threads = paginator.page(page).object_list
60 threads = paginator.page(page).object_list
61 except EmptyPage:
61 except EmptyPage:
62 raise Http404()
62 raise Http404()
63
63
64 params[PARAMETER_THREADS] = threads
64 params[PARAMETER_THREADS] = threads
65 params[CONTEXT_FORM] = form
65 params[CONTEXT_FORM] = form
66 params[PARAMETER_BANNERS] = Banner.objects.order_by('-id').all()
66 params[PARAMETER_BANNERS] = Banner.objects.order_by('-id').all()
67
67
68 self.get_page_context(paginator, params, page)
68 self.get_page_context(paginator, params, page)
69
69
70 return render(request, TEMPLATE, params)
70 return render(request, TEMPLATE, params)
71
71
72 def post(self, request):
72 def post(self, request):
73 form = ThreadForm(request.POST, request.FILES,
73 form = ThreadForm(request.POST, request.FILES,
74 error_class=PlainErrorList)
74 error_class=PlainErrorList)
75 form.session = request.session
75 form.session = request.session
76
76
77 if form.is_valid():
77 if form.is_valid():
78 return self.create_thread(request, form)
78 return self.create_thread(request, form)
79 if form.need_to_ban:
79 if form.need_to_ban:
80 # Ban user because he is suspected to be a bot
80 # Ban user because he is suspected to be a bot
81 self._ban_current_user(request)
81 self._ban_current_user(request)
82
82
83 return self.get(request, form)
83 return self.get(request, form)
84
84
85 def get_page_context(self, paginator, params, page):
85 def get_page_context(self, paginator, params, page):
86 """
86 """
87 Get pagination context variables
87 Get pagination context variables
88 """
88 """
89
89
90 params[PARAMETER_PAGINATOR] = paginator
90 params[PARAMETER_PAGINATOR] = paginator
91 current_page = paginator.page(int(page))
91 current_page = paginator.page(int(page))
92 params[PARAMETER_CURRENT_PAGE] = current_page
92 params[PARAMETER_CURRENT_PAGE] = current_page
93 if current_page.has_previous():
93 if current_page.has_previous():
94 params[PARAMETER_PREV_LINK] = self.get_previous_page_link(
94 params[PARAMETER_PREV_LINK] = self.get_previous_page_link(
95 current_page)
95 current_page)
96 if current_page.has_next():
96 if current_page.has_next():
97 params[PARAMETER_NEXT_LINK] = self.get_next_page_link(current_page)
97 params[PARAMETER_NEXT_LINK] = self.get_next_page_link(current_page)
98
98
99 def get_previous_page_link(self, current_page):
99 def get_previous_page_link(self, current_page):
100 return reverse('index') + '?page=' \
100 return reverse('index') + '?page=' \
101 + str(current_page.previous_page_number())
101 + str(current_page.previous_page_number())
102
102
103 def get_next_page_link(self, current_page):
103 def get_next_page_link(self, current_page):
104 return reverse('index') + '?page=' \
104 return reverse('index') + '?page=' \
105 + str(current_page.next_page_number())
105 + str(current_page.next_page_number())
106
106
107 @staticmethod
108 def parse_tags_string(tag_strings):
109 """
110 Parses tag list string and returns tag object list.
111 """
112
113 tags = []
114
115 if tag_strings:
116 tag_strings = tag_strings.split(TAG_DELIMITER)
117 for tag_name in tag_strings:
118 tag_name = tag_name.strip().lower()
119 if len(tag_name) > 0:
120 tag, created = Tag.objects.get_or_create(name=tag_name)
121 tags.append(tag)
122
123 return tags
124
125 @transaction.atomic
107 @transaction.atomic
126 def create_thread(self, request, form: ThreadForm, html_response=True):
108 def create_thread(self, request, form: ThreadForm, html_response=True):
127 """
109 """
128 Creates a new thread with an opening post.
110 Creates a new thread with an opening post.
129 """
111 """
130
112
131 ip = utils.get_client_ip(request)
113 ip = utils.get_client_ip(request)
132 is_banned = Ban.objects.filter(ip=ip).exists()
114 is_banned = Ban.objects.filter(ip=ip).exists()
133
115
134 if is_banned:
116 if is_banned:
135 if html_response:
117 if html_response:
136 return redirect(BannedView().as_view())
118 return redirect(BannedView().as_view())
137 else:
119 else:
138 return
120 return
139
121
140 data = form.cleaned_data
122 data = form.cleaned_data
141
123
142 title = form.get_title()
124 title = form.get_title()
143 text = data[FORM_TEXT]
125 text = data[FORM_TEXT]
144 file = form.get_file()
126 file = form.get_file()
145 threads = data[FORM_THREADS]
127 threads = data[FORM_THREADS]
146
128
147 text = self._remove_invalid_links(text)
129 text = self._remove_invalid_links(text)
148
130
149 tag_strings = data[FORM_TAGS]
131 tags = data[FORM_TAGS]
150
151 tags = self.parse_tags_string(tag_strings)
152
132
153 post = Post.objects.create_post(title=title, text=text, file=file,
133 post = Post.objects.create_post(title=title, text=text, file=file,
154 ip=ip, tags=tags, opening_posts=threads,
134 ip=ip, tags=tags, opening_posts=threads,
155 tripcode=form.get_tripcode())
135 tripcode=form.get_tripcode())
156
136
157 # This is required to update the threads to which posts we have replied
137 # This is required to update the threads to which posts we have replied
158 # when creating this one
138 # when creating this one
159 post.notify_clients()
139 post.notify_clients()
160
140
161 if html_response:
141 if html_response:
162 return redirect(post.get_absolute_url())
142 return redirect(post.get_absolute_url())
163
143
164 def get_threads(self):
144 def get_threads(self):
165 """
145 """
166 Gets list of threads that will be shown on a page.
146 Gets list of threads that will be shown on a page.
167 """
147 """
168
148
169 return Thread.objects.order_by('-bump_time')\
149 return Thread.objects.order_by('-bump_time')\
170 .exclude(tags__in=self.settings_manager.get_hidden_tags())
150 .exclude(tags__in=self.settings_manager.get_hidden_tags())
@@ -1,243 +1,296 b''
1 from collections import OrderedDict
1 import json
2 import json
2 import logging
3 import logging
3
4
4 import xml.etree.ElementTree as ET
5 import xml.etree.ElementTree as ET
5
6
6 from django.db import transaction
7 from django.db import transaction
8 from django.db.models import Count
7 from django.http import HttpResponse
9 from django.http import HttpResponse
8 from django.shortcuts import get_object_or_404
10 from django.shortcuts import get_object_or_404
9 from django.core import serializers
11 from django.core import serializers
12 from boards.abstracts.settingsmanager import get_settings_manager,\
13 FAV_THREAD_NO_UPDATES
10
14
11 from boards.forms import PostForm, PlainErrorList
15 from boards.forms import PostForm, PlainErrorList
12 from boards.models import Post, Thread, Tag, GlobalId
16 from boards.models import Post, Thread, Tag, GlobalId
13 from boards.models.post.sync import SyncManager
17 from boards.models.post.sync import SyncManager
14 from boards.utils import datetime_to_epoch
18 from boards.utils import datetime_to_epoch
15 from boards.views.thread import ThreadView
19 from boards.views.thread import ThreadView
16 from boards.models.user import Notification
20 from boards.models.user import Notification
17 from boards.mdx_neboard import Parser
21 from boards.mdx_neboard import Parser
18
22
19
23
20 __author__ = 'neko259'
24 __author__ = 'neko259'
21
25
22 PARAMETER_TRUNCATED = 'truncated'
26 PARAMETER_TRUNCATED = 'truncated'
23 PARAMETER_TAG = 'tag'
27 PARAMETER_TAG = 'tag'
24 PARAMETER_OFFSET = 'offset'
28 PARAMETER_OFFSET = 'offset'
25 PARAMETER_DIFF_TYPE = 'type'
29 PARAMETER_DIFF_TYPE = 'type'
26 PARAMETER_POST = 'post'
30 PARAMETER_POST = 'post'
27 PARAMETER_UPDATED = 'updated'
31 PARAMETER_UPDATED = 'updated'
28 PARAMETER_LAST_UPDATE = 'last_update'
32 PARAMETER_LAST_UPDATE = 'last_update'
29 PARAMETER_THREAD = 'thread'
33 PARAMETER_THREAD = 'thread'
30 PARAMETER_UIDS = 'uids'
34 PARAMETER_UIDS = 'uids'
31
35
32 DIFF_TYPE_HTML = 'html'
36 DIFF_TYPE_HTML = 'html'
33 DIFF_TYPE_JSON = 'json'
37 DIFF_TYPE_JSON = 'json'
34
38
35 STATUS_OK = 'ok'
39 STATUS_OK = 'ok'
36 STATUS_ERROR = 'error'
40 STATUS_ERROR = 'error'
37
41
38 logger = logging.getLogger(__name__)
42 logger = logging.getLogger(__name__)
39
43
40
44
41 @transaction.atomic
45 @transaction.atomic
42 def api_get_threaddiff(request):
46 def api_get_threaddiff(request):
43 """
47 """
44 Gets posts that were changed or added since time
48 Gets posts that were changed or added since time
45 """
49 """
46
50
47 thread_id = request.POST.get(PARAMETER_THREAD)
51 thread_id = request.POST.get(PARAMETER_THREAD)
48 uids_str = request.POST.get(PARAMETER_UIDS).strip()
52 uids_str = request.POST.get(PARAMETER_UIDS).strip()
49 uids = uids_str.split(' ')
53 uids = uids_str.split(' ')
50
54
51 thread = get_object_or_404(Post, id=thread_id).get_thread()
55 opening_post = get_object_or_404(Post, id=thread_id)
56 thread = opening_post.get_thread()
52
57
53 json_data = {
58 json_data = {
54 PARAMETER_UPDATED: [],
59 PARAMETER_UPDATED: [],
55 PARAMETER_LAST_UPDATE: None, # TODO Maybe this can be removed already?
60 PARAMETER_LAST_UPDATE: None, # TODO Maybe this can be removed already?
56 }
61 }
57 posts = Post.objects.filter(threads__in=[thread]).exclude(uid__in=uids)
62 posts = Post.objects.filter(threads__in=[thread]).exclude(uid__in=uids)
58
63
59 diff_type = request.GET.get(PARAMETER_DIFF_TYPE, DIFF_TYPE_HTML)
64 diff_type = request.GET.get(PARAMETER_DIFF_TYPE, DIFF_TYPE_HTML)
60
65
61 for post in posts:
66 for post in posts:
62 json_data[PARAMETER_UPDATED].append(get_post_data(post.id, diff_type,
67 json_data[PARAMETER_UPDATED].append(post.get_post_data(
63 request))
68 format_type=diff_type, request=request))
64 json_data[PARAMETER_LAST_UPDATE] = str(thread.last_edit_time)
69 json_data[PARAMETER_LAST_UPDATE] = str(thread.last_edit_time)
65
70
71 # If the tag is favorite, update the counter
72 settings_manager = get_settings_manager(request)
73 favorite = settings_manager.thread_is_fav(opening_post)
74 if favorite:
75 settings_manager.add_or_read_fav_thread(opening_post)
76
66 return HttpResponse(content=json.dumps(json_data))
77 return HttpResponse(content=json.dumps(json_data))
67
78
68
79
69 def api_add_post(request, opening_post_id):
80 def api_add_post(request, opening_post_id):
70 """
81 """
71 Adds a post and return the JSON response for it
82 Adds a post and return the JSON response for it
72 """
83 """
73
84
74 opening_post = get_object_or_404(Post, id=opening_post_id)
85 opening_post = get_object_or_404(Post, id=opening_post_id)
75
86
76 logger.info('Adding post via api...')
87 logger.info('Adding post via api...')
77
88
78 status = STATUS_OK
89 status = STATUS_OK
79 errors = []
90 errors = []
80
91
81 if request.method == 'POST':
92 if request.method == 'POST':
82 form = PostForm(request.POST, request.FILES, error_class=PlainErrorList)
93 form = PostForm(request.POST, request.FILES, error_class=PlainErrorList)
83 form.session = request.session
94 form.session = request.session
84
95
85 if form.need_to_ban:
96 if form.need_to_ban:
86 # Ban user because he is suspected to be a bot
97 # Ban user because he is suspected to be a bot
87 # _ban_current_user(request)
98 # _ban_current_user(request)
88 status = STATUS_ERROR
99 status = STATUS_ERROR
89 if form.is_valid():
100 if form.is_valid():
90 post = ThreadView().new_post(request, form, opening_post,
101 post = ThreadView().new_post(request, form, opening_post,
91 html_response=False)
102 html_response=False)
92 if not post:
103 if not post:
93 status = STATUS_ERROR
104 status = STATUS_ERROR
94 else:
105 else:
95 logger.info('Added post #%d via api.' % post.id)
106 logger.info('Added post #%d via api.' % post.id)
96 else:
107 else:
97 status = STATUS_ERROR
108 status = STATUS_ERROR
98 errors = form.as_json_errors()
109 errors = form.as_json_errors()
99
110
100 response = {
111 response = {
101 'status': status,
112 'status': status,
102 'errors': errors,
113 'errors': errors,
103 }
114 }
104
115
105 return HttpResponse(content=json.dumps(response))
116 return HttpResponse(content=json.dumps(response))
106
117
107
118
108 def get_post(request, post_id):
119 def get_post(request, post_id):
109 """
120 """
110 Gets the html of a post. Used for popups. Post can be truncated if used
121 Gets the html of a post. Used for popups. Post can be truncated if used
111 in threads list with 'truncated' get parameter.
122 in threads list with 'truncated' get parameter.
112 """
123 """
113
124
114 post = get_object_or_404(Post, id=post_id)
125 post = get_object_or_404(Post, id=post_id)
115 truncated = PARAMETER_TRUNCATED in request.GET
126 truncated = PARAMETER_TRUNCATED in request.GET
116
127
117 return HttpResponse(content=post.get_view(truncated=truncated))
128 return HttpResponse(content=post.get_view(truncated=truncated))
118
129
119
130
120 def api_get_threads(request, count):
131 def api_get_threads(request, count):
121 """
132 """
122 Gets the JSON thread opening posts list.
133 Gets the JSON thread opening posts list.
123 Parameters that can be used for filtering:
134 Parameters that can be used for filtering:
124 tag, offset (from which thread to get results)
135 tag, offset (from which thread to get results)
125 """
136 """
126
137
127 if PARAMETER_TAG in request.GET:
138 if PARAMETER_TAG in request.GET:
128 tag_name = request.GET[PARAMETER_TAG]
139 tag_name = request.GET[PARAMETER_TAG]
129 if tag_name is not None:
140 if tag_name is not None:
130 tag = get_object_or_404(Tag, name=tag_name)
141 tag = get_object_or_404(Tag, name=tag_name)
131 threads = tag.get_threads().filter(archived=False)
142 threads = tag.get_threads().filter(archived=False)
132 else:
143 else:
133 threads = Thread.objects.filter(archived=False)
144 threads = Thread.objects.filter(archived=False)
134
145
135 if PARAMETER_OFFSET in request.GET:
146 if PARAMETER_OFFSET in request.GET:
136 offset = request.GET[PARAMETER_OFFSET]
147 offset = request.GET[PARAMETER_OFFSET]
137 offset = int(offset) if offset is not None else 0
148 offset = int(offset) if offset is not None else 0
138 else:
149 else:
139 offset = 0
150 offset = 0
140
151
141 threads = threads.order_by('-bump_time')
152 threads = threads.order_by('-bump_time')
142 threads = threads[offset:offset + int(count)]
153 threads = threads[offset:offset + int(count)]
143
154
144 opening_posts = []
155 opening_posts = []
145 for thread in threads:
156 for thread in threads:
146 opening_post = thread.get_opening_post()
157 opening_post = thread.get_opening_post()
147
158
148 # TODO Add tags, replies and images count
159 # TODO Add tags, replies and images count
149 post_data = get_post_data(opening_post.id, include_last_update=True)
160 post_data = opening_post.get_post_data(include_last_update=True)
150 post_data['bumpable'] = thread.can_bump()
161 post_data['bumpable'] = thread.can_bump()
151 post_data['archived'] = thread.archived
162 post_data['archived'] = thread.archived
152
163
153 opening_posts.append(post_data)
164 opening_posts.append(post_data)
154
165
155 return HttpResponse(content=json.dumps(opening_posts))
166 return HttpResponse(content=json.dumps(opening_posts))
156
167
157
168
158 # TODO Test this
169 # TODO Test this
159 def api_get_tags(request):
170 def api_get_tags(request):
160 """
171 """
161 Gets all tags or user tags.
172 Gets all tags or user tags.
162 """
173 """
163
174
164 # TODO Get favorite tags for the given user ID
175 # TODO Get favorite tags for the given user ID
165
176
166 tags = Tag.objects.get_not_empty_tags()
177 tags = Tag.objects.get_not_empty_tags()
167
178
168 term = request.GET.get('term')
179 term = request.GET.get('term')
169 if term is not None:
180 if term is not None:
170 tags = tags.filter(name__contains=term)
181 tags = tags.filter(name__contains=term)
171
182
172 tag_names = [tag.name for tag in tags]
183 tag_names = [tag.name for tag in tags]
173
184
174 return HttpResponse(content=json.dumps(tag_names))
185 return HttpResponse(content=json.dumps(tag_names))
175
186
176
187
177 # TODO The result can be cached by the thread last update time
188 # TODO The result can be cached by the thread last update time
178 # TODO Test this
189 # TODO Test this
179 def api_get_thread_posts(request, opening_post_id):
190 def api_get_thread_posts(request, opening_post_id):
180 """
191 """
181 Gets the JSON array of thread posts
192 Gets the JSON array of thread posts
182 """
193 """
183
194
184 opening_post = get_object_or_404(Post, id=opening_post_id)
195 opening_post = get_object_or_404(Post, id=opening_post_id)
185 thread = opening_post.get_thread()
196 thread = opening_post.get_thread()
186 posts = thread.get_replies()
197 posts = thread.get_replies()
187
198
188 json_data = {
199 json_data = {
189 'posts': [],
200 'posts': [],
190 'last_update': None,
201 'last_update': None,
191 }
202 }
192 json_post_list = []
203 json_post_list = []
193
204
194 for post in posts:
205 for post in posts:
195 json_post_list.append(get_post_data(post.id))
206 json_post_list.append(post.get_post_data())
196 json_data['last_update'] = datetime_to_epoch(thread.last_edit_time)
207 json_data['last_update'] = datetime_to_epoch(thread.last_edit_time)
197 json_data['posts'] = json_post_list
208 json_data['posts'] = json_post_list
198
209
199 return HttpResponse(content=json.dumps(json_data))
210 return HttpResponse(content=json.dumps(json_data))
200
211
201
212
202 def api_get_notifications(request, username):
213 def api_get_notifications(request, username):
203 last_notification_id_str = request.GET.get('last', None)
214 last_notification_id_str = request.GET.get('last', None)
204 last_id = int(last_notification_id_str) if last_notification_id_str is not None else None
215 last_id = int(last_notification_id_str) if last_notification_id_str is not None else None
205
216
206 posts = Notification.objects.get_notification_posts(username=username,
217 posts = Notification.objects.get_notification_posts(username=username,
207 last=last_id)
218 last=last_id)
208
219
209 json_post_list = []
220 json_post_list = []
210 for post in posts:
221 for post in posts:
211 json_post_list.append(get_post_data(post.id))
222 json_post_list.append(post.get_post_data())
212 return HttpResponse(content=json.dumps(json_post_list))
223 return HttpResponse(content=json.dumps(json_post_list))
213
224
214
225
215 def api_get_post(request, post_id):
226 def api_get_post(request, post_id):
216 """
227 """
217 Gets the JSON of a post. This can be
228 Gets the JSON of a post. This can be
218 used as and API for external clients.
229 used as and API for external clients.
219 """
230 """
220
231
221 post = get_object_or_404(Post, id=post_id)
232 post = get_object_or_404(Post, id=post_id)
222
233
223 json = serializers.serialize("json", [post], fields=(
234 json = serializers.serialize("json", [post], fields=(
224 "pub_time", "_text_rendered", "title", "text", "image",
235 "pub_time", "_text_rendered", "title", "text", "image",
225 "image_width", "image_height", "replies", "tags"
236 "image_width", "image_height", "replies", "tags"
226 ))
237 ))
227
238
228 return HttpResponse(content=json)
239 return HttpResponse(content=json)
229
240
230
241
231 # TODO Remove this method and use post method directly
232 def get_post_data(post_id, format_type=DIFF_TYPE_JSON, request=None,
233 include_last_update=False):
234 post = get_object_or_404(Post, id=post_id)
235 return post.get_post_data(format_type=format_type, request=request,
236 include_last_update=include_last_update)
237
238
239 def api_get_preview(request):
242 def api_get_preview(request):
240 raw_text = request.POST['raw_text']
243 raw_text = request.POST['raw_text']
241
244
242 parser = Parser()
245 parser = Parser()
243 return HttpResponse(content=parser.parse(parser.preparse(raw_text)))
246 return HttpResponse(content=parser.parse(parser.preparse(raw_text)))
247
248
249 def api_get_new_posts(request):
250 """
251 Gets favorite threads and unread posts count.
252 """
253 posts = list()
254
255 include_posts = 'include_posts' in request.GET
256
257 settings_manager = get_settings_manager(request)
258 fav_threads = settings_manager.get_fav_threads()
259 fav_thread_ops = Post.objects.filter(id__in=fav_threads.keys())\
260 .order_by('-pub_time').prefetch_related('thread')
261
262 ops = [{'op': op, 'last_id': fav_threads[str(op.id)]} for op in fav_thread_ops]
263 if include_posts:
264 new_post_threads = Thread.objects.get_new_posts(ops)
265 if new_post_threads:
266 thread_ids = {thread.id: thread for thread in new_post_threads}
267 else:
268 thread_ids = dict()
269
270 for op in fav_thread_ops:
271 fav_thread_dict = dict()
272
273 op_thread = op.get_thread()
274 if op_thread.id in thread_ids:
275 thread = thread_ids[op_thread.id]
276 new_post_count = thread.new_post_count
277 fav_thread_dict['newest_post_link'] = thread.get_replies()\
278 .filter(id__gt=fav_threads[str(op.id)])\
279 .first().get_absolute_url()
280 else:
281 new_post_count = 0
282 fav_thread_dict['new_post_count'] = new_post_count
283
284 fav_thread_dict['id'] = op.id
285
286 fav_thread_dict['post_url'] = op.get_link_view()
287 fav_thread_dict['title'] = op.title
288
289 posts.append(fav_thread_dict)
290 else:
291 fav_thread_dict = dict()
292 fav_thread_dict['new_post_count'] = \
293 Thread.objects.get_new_post_count(ops)
294 posts.append(fav_thread_dict)
295
296 return HttpResponse(content=json.dumps(posts))
@@ -1,140 +1,163 b''
1 import hashlib
1 import hashlib
2 from django.core.exceptions import ObjectDoesNotExist
2 from django.core.exceptions import ObjectDoesNotExist
3 from django.http import Http404
3 from django.http import Http404
4 from django.shortcuts import get_object_or_404, render, redirect
4 from django.shortcuts import get_object_or_404, render, redirect
5 from django.views.generic.edit import FormMixin
5 from django.views.generic.edit import FormMixin
6 from django.utils import timezone
6 from django.utils import timezone
7 from django.utils.dateformat import format
7 from django.utils.dateformat import format
8
8
9 from boards import utils, settings
9 from boards import utils, settings
10 from boards.abstracts.settingsmanager import get_settings_manager
10 from boards.forms import PostForm, PlainErrorList
11 from boards.forms import PostForm, PlainErrorList
11 from boards.models import Post
12 from boards.models import Post
12 from boards.views.base import BaseBoardView, CONTEXT_FORM
13 from boards.views.base import BaseBoardView, CONTEXT_FORM
14 from boards.views.mixins import DispatcherMixin
13 from boards.views.posting_mixin import PostMixin
15 from boards.views.posting_mixin import PostMixin
14
16
15 import neboard
17 import neboard
16
18
17
19
18 CONTEXT_LASTUPDATE = "last_update"
20 CONTEXT_LASTUPDATE = "last_update"
19 CONTEXT_THREAD = 'thread'
21 CONTEXT_THREAD = 'thread'
20 CONTEXT_WS_TOKEN = 'ws_token'
22 CONTEXT_WS_TOKEN = 'ws_token'
21 CONTEXT_WS_PROJECT = 'ws_project'
23 CONTEXT_WS_PROJECT = 'ws_project'
22 CONTEXT_WS_HOST = 'ws_host'
24 CONTEXT_WS_HOST = 'ws_host'
23 CONTEXT_WS_PORT = 'ws_port'
25 CONTEXT_WS_PORT = 'ws_port'
24 CONTEXT_WS_TIME = 'ws_token_time'
26 CONTEXT_WS_TIME = 'ws_token_time'
25 CONTEXT_MODE = 'mode'
27 CONTEXT_MODE = 'mode'
26 CONTEXT_OP = 'opening_post'
28 CONTEXT_OP = 'opening_post'
29 CONTEXT_FAVORITE = 'is_favorite'
27
30
28 FORM_TITLE = 'title'
31 FORM_TITLE = 'title'
29 FORM_TEXT = 'text'
32 FORM_TEXT = 'text'
30 FORM_IMAGE = 'image'
33 FORM_IMAGE = 'image'
31 FORM_THREADS = 'threads'
34 FORM_THREADS = 'threads'
32
35
33
36
34 class ThreadView(BaseBoardView, PostMixin, FormMixin):
37 class ThreadView(BaseBoardView, PostMixin, FormMixin, DispatcherMixin):
35
38
36 def get(self, request, post_id, form: PostForm=None):
39 def get(self, request, post_id, form: PostForm=None):
37 try:
40 try:
38 opening_post = Post.objects.get(id=post_id)
41 opening_post = Post.objects.get(id=post_id)
39 except ObjectDoesNotExist:
42 except ObjectDoesNotExist:
40 raise Http404
43 raise Http404
41
44
45 # If the tag is favorite, update the counter
46 settings_manager = get_settings_manager(request)
47 favorite = settings_manager.thread_is_fav(opening_post)
48 if favorite:
49 settings_manager.add_or_read_fav_thread(opening_post)
50
42 # If this is not OP, don't show it as it is
51 # If this is not OP, don't show it as it is
43 if not opening_post.is_opening():
52 if not opening_post.is_opening():
44 return redirect(opening_post.get_thread().get_opening_post()
53 return redirect(opening_post.get_thread().get_opening_post()
45 .get_absolute_url())
54 .get_absolute_url())
46
55
47 if not form:
56 if not form:
48 form = PostForm(error_class=PlainErrorList)
57 form = PostForm(error_class=PlainErrorList)
49
58
50 thread_to_show = opening_post.get_thread()
59 thread_to_show = opening_post.get_thread()
51
60
52 params = dict()
61 params = dict()
53
62
54 params[CONTEXT_FORM] = form
63 params[CONTEXT_FORM] = form
55 params[CONTEXT_LASTUPDATE] = str(thread_to_show.last_edit_time)
64 params[CONTEXT_LASTUPDATE] = str(thread_to_show.last_edit_time)
56 params[CONTEXT_THREAD] = thread_to_show
65 params[CONTEXT_THREAD] = thread_to_show
57 params[CONTEXT_MODE] = self.get_mode()
66 params[CONTEXT_MODE] = self.get_mode()
58 params[CONTEXT_OP] = opening_post
67 params[CONTEXT_OP] = opening_post
68 params[CONTEXT_FAVORITE] = favorite
59
69
60 if settings.get_bool('External', 'WebsocketsEnabled'):
70 if settings.get_bool('External', 'WebsocketsEnabled'):
61 token_time = format(timezone.now(), u'U')
71 token_time = format(timezone.now(), u'U')
62
72
63 params[CONTEXT_WS_TIME] = token_time
73 params[CONTEXT_WS_TIME] = token_time
64 params[CONTEXT_WS_TOKEN] = utils.get_websocket_token(
74 params[CONTEXT_WS_TOKEN] = utils.get_websocket_token(
65 timestamp=token_time)
75 timestamp=token_time)
66 params[CONTEXT_WS_PROJECT] = neboard.settings.CENTRIFUGE_PROJECT_ID
76 params[CONTEXT_WS_PROJECT] = neboard.settings.CENTRIFUGE_PROJECT_ID
67 params[CONTEXT_WS_HOST] = request.get_host().split(':')[0]
77 params[CONTEXT_WS_HOST] = request.get_host().split(':')[0]
68 params[CONTEXT_WS_PORT] = neboard.settings.CENTRIFUGE_PORT
78 params[CONTEXT_WS_PORT] = neboard.settings.CENTRIFUGE_PORT
69
79
70 params.update(self.get_data(thread_to_show))
80 params.update(self.get_data(thread_to_show))
71
81
72 return render(request, self.get_template(), params)
82 return render(request, self.get_template(), params)
73
83
74 def post(self, request, post_id):
84 def post(self, request, post_id):
75 opening_post = get_object_or_404(Post, id=post_id)
85 opening_post = get_object_or_404(Post, id=post_id)
76
86
77 # If this is not OP, don't show it as it is
87 # If this is not OP, don't show it as it is
78 if not opening_post.is_opening():
88 if not opening_post.is_opening():
79 raise Http404
89 raise Http404
80
90
91 if 'method' in request.POST:
92 self.dispatch_method(request, opening_post)
93
94 return redirect('thread', post_id) # FIXME Different for different modes
95
81 if not opening_post.get_thread().archived:
96 if not opening_post.get_thread().archived:
82 form = PostForm(request.POST, request.FILES,
97 form = PostForm(request.POST, request.FILES,
83 error_class=PlainErrorList)
98 error_class=PlainErrorList)
84 form.session = request.session
99 form.session = request.session
85
100
86 if form.is_valid():
101 if form.is_valid():
87 return self.new_post(request, form, opening_post)
102 return self.new_post(request, form, opening_post)
88 if form.need_to_ban:
103 if form.need_to_ban:
89 # Ban user because he is suspected to be a bot
104 # Ban user because he is suspected to be a bot
90 self._ban_current_user(request)
105 self._ban_current_user(request)
91
106
92 return self.get(request, post_id, form)
107 return self.get(request, post_id, form)
93
108
94 def new_post(self, request, form: PostForm, opening_post: Post=None,
109 def new_post(self, request, form: PostForm, opening_post: Post=None,
95 html_response=True):
110 html_response=True):
96 """
111 """
97 Adds a new post (in thread or as a reply).
112 Adds a new post (in thread or as a reply).
98 """
113 """
99
114
100 ip = utils.get_client_ip(request)
115 ip = utils.get_client_ip(request)
101
116
102 data = form.cleaned_data
117 data = form.cleaned_data
103
118
104 title = form.get_title()
119 title = form.get_title()
105 text = data[FORM_TEXT]
120 text = data[FORM_TEXT]
106 file = form.get_file()
121 file = form.get_file()
107 threads = data[FORM_THREADS]
122 threads = data[FORM_THREADS]
108
123
109 text = self._remove_invalid_links(text)
124 text = self._remove_invalid_links(text)
110
125
111 post_thread = opening_post.get_thread()
126 post_thread = opening_post.get_thread()
112
127
113 post = Post.objects.create_post(title=title, text=text, file=file,
128 post = Post.objects.create_post(title=title, text=text, file=file,
114 thread=post_thread, ip=ip,
129 thread=post_thread, ip=ip,
115 opening_posts=threads,
130 opening_posts=threads,
116 tripcode=form.get_tripcode())
131 tripcode=form.get_tripcode())
117 post.notify_clients()
132 post.notify_clients()
118
133
119 if html_response:
134 if html_response:
120 if opening_post:
135 if opening_post:
121 return redirect(post.get_absolute_url())
136 return redirect(post.get_absolute_url())
122 else:
137 else:
123 return post
138 return post
124
139
125 def get_data(self, thread) -> dict:
140 def get_data(self, thread) -> dict:
126 """
141 """
127 Returns context params for the view.
142 Returns context params for the view.
128 """
143 """
129
144
130 return dict()
145 return dict()
131
146
132 def get_template(self) -> str:
147 def get_template(self) -> str:
133 """
148 """
134 Gets template to show the thread mode on.
149 Gets template to show the thread mode on.
135 """
150 """
136
151
137 pass
152 pass
138
153
139 def get_mode(self) -> str:
154 def get_mode(self) -> str:
140 pass
155 pass
156
157 def subscribe(self, request, opening_post):
158 settings_manager = get_settings_manager(request)
159 settings_manager.add_or_read_fav_thread(opening_post)
160
161 def unsubscribe(self, request, opening_post):
162 settings_manager = get_settings_manager(request)
163 settings_manager.del_fav_thread(opening_post)
@@ -1,10 +1,11 b''
1 httplib2
1 httplib2
2 simplejson
2 simplejson
3 pytube
3 requests
4 requests
4 adjacent
5 adjacent
5 django-haystack
6 django-haystack
6 pillow
7 pillow
7 django>=1.8
8 django>=1.8
8 bbcode
9 bbcode
9 django-debug-toolbar
10 django-debug-toolbar
10 pytz
11 pytz
General Comments 0
You need to be logged in to leave comments. Login now