##// END OF EJS Templates
Store images as regular attachments instead of separate model
neko259 -
r1590:0eb7ac3c default
parent child Browse files
Show More
@@ -0,0 +1,1 b''
1 FILE_DIRECTORY = 'files/'
@@ -0,0 +1,42 b''
1 # -*- coding: utf-8 -*-
2 # Generated by Django 1.9.5 on 2016-05-21 07:43
3 from __future__ import unicode_literals
4
5 from boards.signals import generate_thumb
6 from boards.utils import get_extension
7 from django.db import migrations, models
8
9
10 class Migration(migrations.Migration):
11
12 def images_to_attachments(apps, schema_editor):
13 PostImage = apps.get_model('boards', 'PostImage')
14 Attachment = apps.get_model('boards', 'Attachment')
15
16 count = 0
17 images = PostImage.objects.all()
18 for image in images:
19 file_type = get_extension(image.image.name)
20 attachment = Attachment.objects.create(
21 file=image.image.file, mimetype=file_type, hash=image.hash,
22 alias=image.alias)
23 generate_thumb(attachment)
24 count += 1
25 print('Processed {} of {} images'.format(count, len(images)))
26 for post in image.post_images.all():
27 post.attachments.add(attachment)
28
29 image.image.close()
30
31 dependencies = [
32 ('boards', '0046_auto_20160520_2307'),
33 ]
34
35 operations = [
36 migrations.AddField(
37 model_name='attachment',
38 name='alias',
39 field=models.TextField(blank=True, null=True, unique=True),
40 ),
41 migrations.RunPython(images_to_attachments),
42 ]
@@ -0,0 +1,19 b''
1 # -*- coding: utf-8 -*-
2 # Generated by Django 1.9.5 on 2016-05-21 08:25
3 from __future__ import unicode_literals
4
5 from django.db import migrations
6
7
8 class Migration(migrations.Migration):
9
10 dependencies = [
11 ('boards', '0047_attachment_alias'),
12 ]
13
14 operations = [
15 migrations.RemoveField(
16 model_name='post',
17 name='images',
18 ),
19 ]
@@ -0,0 +1,18 b''
1 # -*- coding: utf-8 -*-
2 # Generated by Django 1.9.5 on 2016-05-21 08:29
3 from __future__ import unicode_literals
4
5 from django.db import migrations
6
7
8 class Migration(migrations.Migration):
9
10 dependencies = [
11 ('boards', '0048_remove_post_images'),
12 ]
13
14 operations = [
15 migrations.DeleteModel(
16 name='PostImage',
17 ),
18 ]
@@ -1,27 +1,28 b''
1 from boards.abstracts.settingsmanager import SessionSettingsManager
1 from boards.abstracts.settingsmanager import SessionSettingsManager
2 from boards.models import PostImage
2 from boards.models import Attachment
3
3
4
4 class AttachmentAlias:
5 class AttachmentAlias:
5 def get_image(alias):
6 def get_image(alias):
6 pass
7 pass
7
8
8
9
9 class SessionAttachmentAlias(AttachmentAlias):
10 class SessionAttachmentAlias(AttachmentAlias):
10 def __init__(self, session):
11 def __init__(self, session):
11 self.session = session
12 self.session = session
12
13
13 def get_image(self, alias):
14 def get_image(self, alias):
14 settings_manager = SessionSettingsManager(self.session)
15 settings_manager = SessionSettingsManager(self.session)
15 return settings_manager.get_image_by_alias(alias)
16 return settings_manager.get_image_by_alias(alias)
16
17
17
18
18 class ModelAttachmentAlias(AttachmentAlias):
19 class ModelAttachmentAlias(AttachmentAlias):
19 def get_image(self, alias):
20 def get_image(self, alias):
20 return PostImage.objects.filter(alias=alias).first()
21 return Attachment.objects.filter(alias=alias).first()
21
22
22
23
23 def get_image_by_alias(alias, session):
24 def get_image_by_alias(alias, session):
24 image = SessionAttachmentAlias(session).get_image(alias) or ModelAttachmentAlias().get_image(alias)
25 image = SessionAttachmentAlias(session).get_image(alias) or ModelAttachmentAlias().get_image(alias)
25
26
26 if image is not None:
27 if image is not None:
27 return image
28 return image
@@ -1,156 +1,156 b''
1 from boards.models.attachment import FILE_TYPES_IMAGE
1 from django.contrib import admin
2 from django.contrib import admin
2 from django.utils.translation import ugettext_lazy as _
3 from django.utils.translation import ugettext_lazy as _
3 from django.core.urlresolvers import reverse
4 from django.core.urlresolvers import reverse
4 from django.db.models import F
5 from boards.models import Post, Tag, Ban, Thread, Banner, Attachment, KeyPair, GlobalId
5 from boards.models import Post, Tag, Ban, Thread, Banner, PostImage, KeyPair, GlobalId
6
6
7
7
8 @admin.register(Post)
8 @admin.register(Post)
9 class PostAdmin(admin.ModelAdmin):
9 class PostAdmin(admin.ModelAdmin):
10
10
11 list_display = ('id', 'title', 'text', 'poster_ip', 'linked_images',
11 list_display = ('id', 'title', 'text', 'poster_ip', 'linked_images',
12 'foreign')
12 'foreign')
13 list_filter = ('pub_time',)
13 list_filter = ('pub_time',)
14 search_fields = ('id', 'title', 'text', 'poster_ip')
14 search_fields = ('id', 'title', 'text', 'poster_ip')
15 exclude = ('referenced_posts', 'refmap', 'images', 'global_id')
15 exclude = ('referenced_posts', 'refmap', 'images', 'global_id')
16 readonly_fields = ('poster_ip', 'threads', 'thread', 'linked_images',
16 readonly_fields = ('poster_ip', 'threads', 'thread', 'linked_images',
17 'attachments', 'uid', 'url', 'pub_time', 'opening', 'linked_global_id',
17 'attachments', 'uid', 'url', 'pub_time', 'opening', 'linked_global_id',
18 'version', 'foreign')
18 'version', 'foreign')
19
19
20 def ban_poster(self, request, queryset):
20 def ban_poster(self, request, queryset):
21 bans = 0
21 bans = 0
22 for post in queryset:
22 for post in queryset:
23 poster_ip = post.poster_ip
23 poster_ip = post.poster_ip
24 ban, created = Ban.objects.get_or_create(ip=poster_ip)
24 ban, created = Ban.objects.get_or_create(ip=poster_ip)
25 if created:
25 if created:
26 bans += 1
26 bans += 1
27 self.message_user(request, _('{} posters were banned').format(bans))
27 self.message_user(request, _('{} posters were banned').format(bans))
28
28
29 def ban_with_hiding(self, request, queryset):
29 def ban_with_hiding(self, request, queryset):
30 bans = 0
30 bans = 0
31 hidden = 0
31 hidden = 0
32 for post in queryset:
32 for post in queryset:
33 poster_ip = post.poster_ip
33 poster_ip = post.poster_ip
34 ban, created = Ban.objects.get_or_create(ip=poster_ip)
34 ban, created = Ban.objects.get_or_create(ip=poster_ip)
35 if created:
35 if created:
36 bans += 1
36 bans += 1
37 posts = Post.objects.filter(poster_ip=poster_ip, id__gte=post.id)
37 posts = Post.objects.filter(poster_ip=poster_ip, id__gte=post.id)
38 hidden += posts.count()
38 hidden += posts.count()
39 posts.update(hidden=True)
39 posts.update(hidden=True)
40 self.message_user(request, _('{} posters were banned, {} messages were hidden').format(bans, hidden))
40 self.message_user(request, _('{} posters were banned, {} messages were hidden').format(bans, hidden))
41
41
42 def linked_images(self, obj: Post):
42 def linked_images(self, obj: Post):
43 images = obj.images.all()
43 images = obj.attachments.filter(mimetype__in=FILE_TYPES_IMAGE)
44 image_urls = ['<a href="{}"><img src="{}" /></a>'.format(
44 image_urls = ['<a href="{}"><img src="{}" /></a>'.format(
45 reverse('admin:%s_%s_change' % (image._meta.app_label,
45 reverse('admin:%s_%s_change' % (image._meta.app_label,
46 image._meta.model_name),
46 image._meta.model_name),
47 args=[image.id]), image.image.url_200x150) for image in images]
47 args=[image.id]), image.file.url_200x150) for image in images]
48 return ', '.join(image_urls)
48 return ', '.join(image_urls)
49 linked_images.allow_tags = True
49 linked_images.allow_tags = True
50
50
51 def linked_global_id(self, obj: Post):
51 def linked_global_id(self, obj: Post):
52 global_id = obj.global_id
52 global_id = obj.global_id
53 if global_id is not None:
53 if global_id is not None:
54 return '<a href="{}">{}</a>'.format(
54 return '<a href="{}">{}</a>'.format(
55 reverse('admin:%s_%s_change' % (global_id._meta.app_label,
55 reverse('admin:%s_%s_change' % (global_id._meta.app_label,
56 global_id._meta.model_name),
56 global_id._meta.model_name),
57 args=[global_id.id]), str(global_id))
57 args=[global_id.id]), str(global_id))
58 linked_global_id.allow_tags = True
58 linked_global_id.allow_tags = True
59
59
60 def save_model(self, request, obj, form, change):
60 def save_model(self, request, obj, form, change):
61 obj.increment_version()
61 obj.increment_version()
62 obj.save()
62 obj.save()
63 obj.clear_cache()
63 obj.clear_cache()
64
64
65 def foreign(self, obj: Post):
65 def foreign(self, obj: Post):
66 return obj is not None and obj.global_id is not None and\
66 return obj is not None and obj.global_id is not None and\
67 not obj.global_id.is_local()
67 not obj.global_id.is_local()
68
68
69 actions = ['ban_poster', 'ban_with_hiding']
69 actions = ['ban_poster', 'ban_with_hiding']
70
70
71
71
72 @admin.register(Tag)
72 @admin.register(Tag)
73 class TagAdmin(admin.ModelAdmin):
73 class TagAdmin(admin.ModelAdmin):
74
74
75 def thread_count(self, obj: Tag) -> int:
75 def thread_count(self, obj: Tag) -> int:
76 return obj.get_thread_count()
76 return obj.get_thread_count()
77
77
78 def display_children(self, obj: Tag):
78 def display_children(self, obj: Tag):
79 return ', '.join([str(child) for child in obj.get_children().all()])
79 return ', '.join([str(child) for child in obj.get_children().all()])
80
80
81 def save_model(self, request, obj, form, change):
81 def save_model(self, request, obj, form, change):
82 super().save_model(request, obj, form, change)
82 super().save_model(request, obj, form, change)
83 for thread in obj.get_threads().all():
83 for thread in obj.get_threads().all():
84 thread.refresh_tags()
84 thread.refresh_tags()
85 list_display = ('name', 'thread_count', 'display_children')
85 list_display = ('name', 'thread_count', 'display_children')
86 search_fields = ('name',)
86 search_fields = ('name',)
87
87
88
88
89 @admin.register(Thread)
89 @admin.register(Thread)
90 class ThreadAdmin(admin.ModelAdmin):
90 class ThreadAdmin(admin.ModelAdmin):
91
91
92 def title(self, obj: Thread) -> str:
92 def title(self, obj: Thread) -> str:
93 return obj.get_opening_post().get_title()
93 return obj.get_opening_post().get_title()
94
94
95 def reply_count(self, obj: Thread) -> int:
95 def reply_count(self, obj: Thread) -> int:
96 return obj.get_reply_count()
96 return obj.get_reply_count()
97
97
98 def ip(self, obj: Thread):
98 def ip(self, obj: Thread):
99 return obj.get_opening_post().poster_ip
99 return obj.get_opening_post().poster_ip
100
100
101 def display_tags(self, obj: Thread):
101 def display_tags(self, obj: Thread):
102 return ', '.join([str(tag) for tag in obj.get_tags().all()])
102 return ', '.join([str(tag) for tag in obj.get_tags().all()])
103
103
104 def op(self, obj: Thread):
104 def op(self, obj: Thread):
105 return obj.get_opening_post_id()
105 return obj.get_opening_post_id()
106
106
107 # Save parent tags when editing tags
107 # Save parent tags when editing tags
108 def save_related(self, request, form, formsets, change):
108 def save_related(self, request, form, formsets, change):
109 super().save_related(request, form, formsets, change)
109 super().save_related(request, form, formsets, change)
110 form.instance.refresh_tags()
110 form.instance.refresh_tags()
111
111
112 def save_model(self, request, obj, form, change):
112 def save_model(self, request, obj, form, change):
113 op = obj.get_opening_post()
113 op = obj.get_opening_post()
114 op.increment_version()
114 op.increment_version()
115 op.save(update_fields=['version'])
115 op.save(update_fields=['version'])
116 obj.save()
116 obj.save()
117 op.clear_cache()
117 op.clear_cache()
118
118
119 list_display = ('id', 'op', 'title', 'reply_count', 'status', 'ip',
119 list_display = ('id', 'op', 'title', 'reply_count', 'status', 'ip',
120 'display_tags')
120 'display_tags')
121 list_filter = ('bump_time', 'status')
121 list_filter = ('bump_time', 'status')
122 search_fields = ('id', 'title')
122 search_fields = ('id', 'title')
123 filter_horizontal = ('tags',)
123 filter_horizontal = ('tags',)
124
124
125
125
126 @admin.register(KeyPair)
126 @admin.register(KeyPair)
127 class KeyPairAdmin(admin.ModelAdmin):
127 class KeyPairAdmin(admin.ModelAdmin):
128 list_display = ('public_key', 'primary')
128 list_display = ('public_key', 'primary')
129 list_filter = ('primary',)
129 list_filter = ('primary',)
130 search_fields = ('public_key',)
130 search_fields = ('public_key',)
131
131
132
132
133 @admin.register(Ban)
133 @admin.register(Ban)
134 class BanAdmin(admin.ModelAdmin):
134 class BanAdmin(admin.ModelAdmin):
135 list_display = ('ip', 'can_read')
135 list_display = ('ip', 'can_read')
136 list_filter = ('can_read',)
136 list_filter = ('can_read',)
137 search_fields = ('ip',)
137 search_fields = ('ip',)
138
138
139
139
140 @admin.register(Banner)
140 @admin.register(Banner)
141 class BannerAdmin(admin.ModelAdmin):
141 class BannerAdmin(admin.ModelAdmin):
142 list_display = ('title', 'text')
142 list_display = ('title', 'text')
143
143
144
144
145 @admin.register(PostImage)
145 @admin.register(Attachment)
146 class PostImageAdmin(admin.ModelAdmin):
146 class AttachmentAdmin(admin.ModelAdmin):
147 search_fields = ('alias',)
147 search_fields = ('alias',)
148
148
149
149
150 @admin.register(GlobalId)
150 @admin.register(GlobalId)
151 class GlobalIdAdmin(admin.ModelAdmin):
151 class GlobalIdAdmin(admin.ModelAdmin):
152 def is_linked(self, obj):
152 def is_linked(self, obj):
153 return Post.objects.filter(global_id=obj).exists()
153 return Post.objects.filter(global_id=obj).exists()
154
154
155 list_display = ('__str__', 'is_linked',)
155 list_display = ('__str__', 'is_linked',)
156 readonly_fields = ('content',)
156 readonly_fields = ('content',)
@@ -1,46 +1,36 b''
1 import os
1 import os
2 from boards.abstracts.constants import FILE_DIRECTORY
2
3
3 from django.core.management import BaseCommand
4 from django.core.management import BaseCommand
4 from django.db import transaction
5 from django.db import transaction
5 from boards.models import Attachment
6 from boards.models import Attachment
6 from boards.models.attachment import FILES_DIRECTORY
7
7
8 from boards.models.image import IMAGES_DIRECTORY, PostImage, IMAGE_THUMB_SIZE
9 from neboard.settings import MEDIA_ROOT
8 from neboard.settings import MEDIA_ROOT
10
9
11
10
12 __author__ = 'neko259'
11 __author__ = 'neko259'
13
12
13 THUMB_SIZE = (200, 150)
14
14
15
15 class Command(BaseCommand):
16 class Command(BaseCommand):
16 help = 'Remove files whose models were deleted'
17 help = 'Remove files whose models were deleted'
17
18
18 @transaction.atomic
19 @transaction.atomic
19 def handle(self, *args, **options):
20 def handle(self, *args, **options):
20 count = 0
21 thumb_prefix = '.{}x{}'.format(*THUMB_SIZE)
21 thumb_prefix = '.{}x{}'.format(*IMAGE_THUMB_SIZE)
22
23 model_files = os.listdir(MEDIA_ROOT + IMAGES_DIRECTORY)
24 for file in model_files:
25 image_name = file if thumb_prefix not in file else file.replace(thumb_prefix, '')
26 found = PostImage.objects.filter(
27 image=IMAGES_DIRECTORY + image_name).exists()
28
29 if not found:
30 print('Missing {}'.format(image_name))
31 os.remove(MEDIA_ROOT + IMAGES_DIRECTORY + file)
32 count += 1
33 print('Deleted {} image files.'.format(count))
34
22
35 count = 0
23 count = 0
36 model_files = os.listdir(MEDIA_ROOT + FILES_DIRECTORY)
24 model_files = os.listdir(MEDIA_ROOT + FILE_DIRECTORY)
37 for file in model_files:
25 for file in model_files:
38 found = Attachment.objects.filter(file=FILES_DIRECTORY + file)\
26 model_filename = file if thumb_prefix not in file else file.replace(
27 thumb_prefix, '')
28 found = Attachment.objects.filter(file=FILE_DIRECTORY + model_filename)\
39 .exists()
29 .exists()
40
30
41 if not found:
31 if not found:
42 print('Missing {}'.format(file))
32 print('Missing {}'.format(file))
43 os.remove(MEDIA_ROOT + FILES_DIRECTORY + file)
33 os.remove(MEDIA_ROOT + FILE_DIRECTORY + file)
44 count += 1
34 count += 1
45
35
46 print('Deleted {} attachment files.'.format(count))
36 print('Deleted {} attachment files.'.format(count))
@@ -1,113 +1,112 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 models, migrations
4 from django.db import models, migrations
5 import boards.models.image
6 import boards.models.base
5 import boards.models.base
7 import boards.thumbs
6 import boards.thumbs
8
7
9
8
10 class Migration(migrations.Migration):
9 class Migration(migrations.Migration):
11
10
12 dependencies = [
11 dependencies = [
13 ]
12 ]
14
13
15 operations = [
14 operations = [
16 migrations.CreateModel(
15 migrations.CreateModel(
17 name='Ban',
16 name='Ban',
18 fields=[
17 fields=[
19 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
18 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
20 ('ip', models.GenericIPAddressField()),
19 ('ip', models.GenericIPAddressField()),
21 ('reason', models.CharField(max_length=200, default='Auto')),
20 ('reason', models.CharField(max_length=200, default='Auto')),
22 ('can_read', models.BooleanField(default=True)),
21 ('can_read', models.BooleanField(default=True)),
23 ],
22 ],
24 options={
23 options={
25 },
24 },
26 bases=(models.Model,),
25 bases=(models.Model,),
27 ),
26 ),
28 migrations.CreateModel(
27 migrations.CreateModel(
29 name='Post',
28 name='Post',
30 fields=[
29 fields=[
31 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
30 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
32 ('title', models.CharField(max_length=200)),
31 ('title', models.CharField(max_length=200)),
33 ('pub_time', models.DateTimeField()),
32 ('pub_time', models.DateTimeField()),
34 ('text', models.TextField(null=True, blank=True)),
33 ('text', models.TextField(null=True, blank=True)),
35 ('text_markup_type', models.CharField(choices=[('', '--'), ('bbcode', 'bbcode')], max_length=30, default='bbcode')),
34 ('text_markup_type', models.CharField(choices=[('', '--'), ('bbcode', 'bbcode')], max_length=30, default='bbcode')),
36 ('poster_ip', models.GenericIPAddressField()),
35 ('poster_ip', models.GenericIPAddressField()),
37 ('_text_rendered', models.TextField(editable=False)),
36 ('_text_rendered', models.TextField(editable=False)),
38 ('poster_user_agent', models.TextField()),
37 ('poster_user_agent', models.TextField()),
39 ('last_edit_time', models.DateTimeField()),
38 ('last_edit_time', models.DateTimeField()),
40 ('refmap', models.TextField(null=True, blank=True)),
39 ('refmap', models.TextField(null=True, blank=True)),
41 ],
40 ],
42 options={
41 options={
43 'ordering': ('id',),
42 'ordering': ('id',),
44 },
43 },
45 bases=(models.Model, boards.models.base.Viewable),
44 bases=(models.Model, boards.models.base.Viewable),
46 ),
45 ),
47 migrations.CreateModel(
46 migrations.CreateModel(
48 name='PostImage',
47 name='PostImage',
49 fields=[
48 fields=[
50 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
49 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
51 ('width', models.IntegerField(default=0)),
50 ('width', models.IntegerField(default=0)),
52 ('height', models.IntegerField(default=0)),
51 ('height', models.IntegerField(default=0)),
53 ('pre_width', models.IntegerField(default=0)),
52 ('pre_width', models.IntegerField(default=0)),
54 ('pre_height', models.IntegerField(default=0)),
53 ('pre_height', models.IntegerField(default=0)),
55 ('image', boards.thumbs.ImageWithThumbsField(height_field='height', width_field='width', blank=True)),
54 ('image', boards.thumbs.ImageWithThumbsField(height_field='height', width_field='width', blank=True)),
56 ('hash', models.CharField(max_length=36)),
55 ('hash', models.CharField(max_length=36)),
57 ],
56 ],
58 options={
57 options={
59 'ordering': ('id',),
58 'ordering': ('id',),
60 },
59 },
61 bases=(models.Model,),
60 bases=(models.Model,),
62 ),
61 ),
63 migrations.CreateModel(
62 migrations.CreateModel(
64 name='Tag',
63 name='Tag',
65 fields=[
64 fields=[
66 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
65 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
67 ('name', models.CharField(db_index=True, max_length=100)),
66 ('name', models.CharField(db_index=True, max_length=100)),
68 ],
67 ],
69 options={
68 options={
70 'ordering': ('name',),
69 'ordering': ('name',),
71 },
70 },
72 bases=(models.Model, boards.models.base.Viewable),
71 bases=(models.Model, boards.models.base.Viewable),
73 ),
72 ),
74 migrations.CreateModel(
73 migrations.CreateModel(
75 name='Thread',
74 name='Thread',
76 fields=[
75 fields=[
77 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
76 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
78 ('bump_time', models.DateTimeField()),
77 ('bump_time', models.DateTimeField()),
79 ('last_edit_time', models.DateTimeField()),
78 ('last_edit_time', models.DateTimeField()),
80 ('archived', models.BooleanField(default=False)),
79 ('archived', models.BooleanField(default=False)),
81 ('bumpable', models.BooleanField(default=True)),
80 ('bumpable', models.BooleanField(default=True)),
82 ('replies', models.ManyToManyField(null=True, related_name='tre+', to='boards.Post', blank=True)),
81 ('replies', models.ManyToManyField(null=True, related_name='tre+', to='boards.Post', blank=True)),
83 ('tags', models.ManyToManyField(to='boards.Tag')),
82 ('tags', models.ManyToManyField(to='boards.Tag')),
84 ],
83 ],
85 options={
84 options={
86 },
85 },
87 bases=(models.Model,),
86 bases=(models.Model,),
88 ),
87 ),
89 migrations.AddField(
88 migrations.AddField(
90 model_name='tag',
89 model_name='tag',
91 name='threads',
90 name='threads',
92 field=models.ManyToManyField(null=True, related_name='tag+', to='boards.Thread', blank=True),
91 field=models.ManyToManyField(null=True, related_name='tag+', to='boards.Thread', blank=True),
93 preserve_default=True,
92 preserve_default=True,
94 ),
93 ),
95 migrations.AddField(
94 migrations.AddField(
96 model_name='post',
95 model_name='post',
97 name='images',
96 name='images',
98 field=models.ManyToManyField(null=True, db_index=True, related_name='ip+', to='boards.PostImage', blank=True),
97 field=models.ManyToManyField(null=True, db_index=True, related_name='ip+', to='boards.PostImage', blank=True),
99 preserve_default=True,
98 preserve_default=True,
100 ),
99 ),
101 migrations.AddField(
100 migrations.AddField(
102 model_name='post',
101 model_name='post',
103 name='referenced_posts',
102 name='referenced_posts',
104 field=models.ManyToManyField(null=True, db_index=True, related_name='rfp+', to='boards.Post', blank=True),
103 field=models.ManyToManyField(null=True, db_index=True, related_name='rfp+', to='boards.Post', blank=True),
105 preserve_default=True,
104 preserve_default=True,
106 ),
105 ),
107 migrations.AddField(
106 migrations.AddField(
108 model_name='post',
107 model_name='post',
109 name='thread_new',
108 name='thread_new',
110 field=models.ForeignKey(null=True, default=None, to='boards.Thread'),
109 field=models.ForeignKey(null=True, default=None, to='boards.Thread'),
111 preserve_default=True,
110 preserve_default=True,
112 ),
111 ),
113 ]
112 ]
@@ -1,14 +1,13 b''
1 STATUS_ACTIVE = 'active'
1 STATUS_ACTIVE = 'active'
2 STATUS_BUMPLIMIT = 'bumplimit'
2 STATUS_BUMPLIMIT = 'bumplimit'
3 STATUS_ARCHIVE = 'archived'
3 STATUS_ARCHIVE = 'archived'
4
4
5
5
6 from boards.models.sync_key import KeyPair
6 from boards.models.sync_key import KeyPair
7 from boards.models.signature import GlobalId, Signature
7 from boards.models.signature import GlobalId, Signature
8 from boards.models.image import PostImage
9 from boards.models.attachment import Attachment
8 from boards.models.attachment import Attachment
10 from boards.models.thread import Thread
9 from boards.models.thread import Thread
11 from boards.models.post import Post
10 from boards.models.post import Post
12 from boards.models.tag import Tag
11 from boards.models.tag import Tag
13 from boards.models.user import Ban
12 from boards.models.user import Ban
14 from boards.models.banner import Banner
13 from boards.models.banner import Banner
@@ -1,42 +1,78 b''
1 import boards
2 from boards.models import STATUS_ARCHIVE
3 from django.core.files.images import get_image_dimensions
1 from django.db import models
4 from django.db import models
2
5
3 from boards import utils
6 from boards import utils
4 from boards.models.attachment.viewers import get_viewers, AbstractViewer
7 from boards.models.attachment.viewers import get_viewers, AbstractViewer, \
5 from boards.utils import get_upload_filename, get_file_mimetype, get_extension
8 FILE_TYPES_IMAGE
9 from boards.utils import get_upload_filename, get_extension, cached_result
6
10
7
11
8 class AttachmentManager(models.Manager):
12 class AttachmentManager(models.Manager):
9 def create_with_hash(self, file):
13 def create_with_hash(self, file):
10 file_hash = utils.get_file_hash(file)
14 file_hash = utils.get_file_hash(file)
11 existing = self.filter(hash=file_hash)
15 existing = self.filter(hash=file_hash)
12 if len(existing) > 0:
16 if len(existing) > 0:
13 attachment = existing[0]
17 attachment = existing[0]
14 else:
18 else:
15 # FIXME Use full mimetype here, need to modify viewers too
19 # FIXME Use full mimetype here, need to modify viewers too
16 file_type = get_extension(file.name)
20 file_type = get_extension(file.name)
17 attachment = self.create(file=file, mimetype=file_type,
21 attachment = self.create(file=file, mimetype=file_type,
18 hash=file_hash)
22 hash=file_hash)
19
23
20 return attachment
24 return attachment
21
25
26 def get_random_images(self, count, tags=None):
27 images = self.filter(mimetype__in=FILE_TYPES_IMAGE).exclude(
28 post_attachments__thread__status=STATUS_ARCHIVE)
29 if tags is not None:
30 images = images.filter(post_attachments__threads__tags__in=tags)
31 return images.order_by('?')[:count]
32
22
33
23 class Attachment(models.Model):
34 class Attachment(models.Model):
24 objects = AttachmentManager()
35 objects = AttachmentManager()
25
36
26 file = models.FileField(upload_to=get_upload_filename)
37 file = models.FileField(upload_to=get_upload_filename)
27 mimetype = models.CharField(max_length=50)
38 mimetype = models.CharField(max_length=50)
28 hash = models.CharField(max_length=36)
39 hash = models.CharField(max_length=36)
40 alias = models.TextField(unique=True, null=True, blank=True)
29
41
30 def get_view(self):
42 def get_view(self):
31 file_viewer = None
43 file_viewer = None
32 for viewer in get_viewers():
44 for viewer in get_viewers():
33 if viewer.supports(self.mimetype):
45 if viewer.supports(self.mimetype):
34 file_viewer = viewer
46 file_viewer = viewer
35 break
47 break
36 if file_viewer is None:
48 if file_viewer is None:
37 file_viewer = AbstractViewer
49 file_viewer = AbstractViewer
38
50
39 return file_viewer(self.file, self.mimetype).get_view()
51 return file_viewer(self.file, self.mimetype).get_view()
40
52
41 def __str__(self):
53 def __str__(self):
42 return self.file.url
54 return self.file.url
55
56 def get_random_associated_post(self):
57 posts = boards.models.Post.objects.filter(attachments__in=[self])
58 return posts.order_by('?').first()
59
60 @cached_result()
61 def get_size(self):
62 if self.mimetype in FILE_TYPES_IMAGE:
63 return get_image_dimensions(self.file)
64 else:
65 return 200, 150
66
67 def get_thumb_url(self):
68 split = self.file.url.rsplit('.', 1)
69 w, h = 200, 150
70 return '%s.%sx%s.%s' % (split[0], w, h, split[1])
71
72 @cached_result()
73 def get_preview_size(self):
74 if self.mimetype in FILE_TYPES_IMAGE:
75 preview_path = self.file.path.replace('.', '.200x150.')
76 return get_image_dimensions(preview_path)
77 else:
78 return 200, 150
@@ -1,85 +1,128 b''
1 from django.core.files.images import get_image_dimensions
1 from django.template.defaultfilters import filesizeformat
2 from django.template.defaultfilters import filesizeformat
2 from django.contrib.staticfiles.templatetags.staticfiles import static
3 from django.contrib.staticfiles.templatetags.staticfiles import static
3
4
4 FILE_STUB_IMAGE = 'images/file.png'
5 FILE_STUB_IMAGE = 'images/file.png'
5
6
6 FILE_TYPES_VIDEO = (
7 FILE_TYPES_VIDEO = (
7 'webm',
8 'webm',
8 'mp4',
9 'mp4',
9 'mpeg',
10 'mpeg',
10 'ogv',
11 'ogv',
11 )
12 )
12 FILE_TYPE_SVG = 'svg'
13 FILE_TYPE_SVG = 'svg'
13 FILE_TYPES_AUDIO = (
14 FILE_TYPES_AUDIO = (
14 'ogg',
15 'ogg',
15 'mp3',
16 'mp3',
16 'opus',
17 'opus',
17 )
18 )
19 FILE_TYPES_IMAGE = (
20 'jpeg',
21 'jpg',
22 'png',
23 'bmp',
24 'gif',
25 )
18
26
19 PLAIN_FILE_FORMATS = {
27 PLAIN_FILE_FORMATS = {
20 'pdf': 'pdf',
28 'pdf': 'pdf',
21 'djvu': 'djvu',
29 'djvu': 'djvu',
22 'txt': 'txt',
30 'txt': 'txt',
23 }
31 }
24
32
33 CSS_CLASS_IMAGE = 'image'
34 CSS_CLASS_THUMB = 'thumb'
35
25
36
26 def get_viewers():
37 def get_viewers():
27 return AbstractViewer.__subclasses__()
38 return AbstractViewer.__subclasses__()
28
39
29
40
30 class AbstractViewer:
41 class AbstractViewer:
31 def __init__(self, file, file_type):
42 def __init__(self, file, file_type):
32 self.file = file
43 self.file = file
33 self.file_type = file_type
44 self.file_type = file_type
34
45
35 @staticmethod
46 @staticmethod
36 def supports(file_type):
47 def supports(file_type):
37 return True
48 return True
38
49
39 def get_view(self):
50 def get_view(self):
40 return '<div class="image">'\
51 return '<div class="image">'\
41 '{}'\
52 '{}'\
42 '<div class="image-metadata"><a href="{}" download >{}, {}</a></div>'\
53 '<div class="image-metadata"><a href="{}" download >{}, {}</a></div>'\
43 '</div>'.format(self.get_format_view(), self.file.url,
54 '</div>'.format(self.get_format_view(), self.file.url,
44 self.file_type, filesizeformat(self.file.size))
55 self.file_type, filesizeformat(self.file.size))
45
56
46 def get_format_view(self):
57 def get_format_view(self):
47 if self.file_type in PLAIN_FILE_FORMATS:
58 if self.file_type in PLAIN_FILE_FORMATS:
48 image = 'images/fileformats/{}.png'.format(
59 image = 'images/fileformats/{}.png'.format(
49 PLAIN_FILE_FORMATS[self.file_type])
60 PLAIN_FILE_FORMATS[self.file_type])
50 else:
61 else:
51 image = FILE_STUB_IMAGE
62 image = FILE_STUB_IMAGE
52
63
53 return '<a href="{}">'\
64 return '<a href="{}">'\
54 '<img src="{}" width="200" height="150"/>'\
65 '<img src="{}" width="200" height="150"/>'\
55 '</a>'.format(self.file.url, static(image))
66 '</a>'.format(self.file.url, static(image))
56
67
57
68
58 class VideoViewer(AbstractViewer):
69 class VideoViewer(AbstractViewer):
59 @staticmethod
70 @staticmethod
60 def supports(file_type):
71 def supports(file_type):
61 return file_type in FILE_TYPES_VIDEO
72 return file_type in FILE_TYPES_VIDEO
62
73
63 def get_format_view(self):
74 def get_format_view(self):
64 return '<video width="200" height="150" controls src="{}"></video>'\
75 return '<video width="200" height="150" controls src="{}"></video>'\
65 .format(self.file.url)
76 .format(self.file.url)
66
77
67
78
68 class AudioViewer(AbstractViewer):
79 class AudioViewer(AbstractViewer):
69 @staticmethod
80 @staticmethod
70 def supports(file_type):
81 def supports(file_type):
71 return file_type in FILE_TYPES_AUDIO
82 return file_type in FILE_TYPES_AUDIO
72
83
73 def get_format_view(self):
84 def get_format_view(self):
74 return '<audio controls src="{}"></audio>'.format(self.file.url)
85 return '<audio controls src="{}"></audio>'.format(self.file.url)
75
86
76
87
77 class SvgViewer(AbstractViewer):
88 class SvgViewer(AbstractViewer):
78 @staticmethod
89 @staticmethod
79 def supports(file_type):
90 def supports(file_type):
80 return file_type == FILE_TYPE_SVG
91 return file_type == FILE_TYPE_SVG
81
92
82 def get_format_view(self):
93 def get_format_view(self):
83 return '<a class="thumb" href="{}">'\
94 return '<a class="thumb" href="{}">'\
84 '<img class="post-image-preview" width="200" height="150" src="{}" />'\
95 '<img class="post-image-preview" width="200" height="150" src="{}" />'\
85 '</a>'.format(self.file.url, self.file.url)
96 '</a>'.format(self.file.url, self.file.url)
97
98
99 class ImageViewer(AbstractViewer):
100 @staticmethod
101 def supports(file_type):
102 return file_type in FILE_TYPES_IMAGE
103
104 def get_format_view(self):
105 metadata = '{}, {}'.format(self.file.name.split('.')[-1],
106 filesizeformat(self.file.size))
107 width, height = get_image_dimensions(self.file.file)
108 preview_path = self.file.path.replace('.', '.200x150.')
109 pre_width, pre_height = get_image_dimensions(preview_path)
110
111 split = self.file.url.rsplit('.', 1)
112 w, h = 200, 150
113 thumb_url = '%s.%sx%s.%s' % (split[0], w, h, split[1])
114
115 return '<a class="{}" href="{full}">' \
116 '<img class="post-image-preview"' \
117 ' src="{}"' \
118 ' width="{}"' \
119 ' height="{}"' \
120 ' data-width="{}"' \
121 ' data-height="{}" />' \
122 '</a>' \
123 .format(CSS_CLASS_THUMB,
124 thumb_url,
125 str(pre_width),
126 str(pre_height), str(width), str(height),
127 full=self.file.url, image_meta=metadata)
128
@@ -1,397 +1,394 b''
1 import uuid
1 import uuid
2
2
3 import re
3 import re
4 from boards import settings
4 from boards import settings
5 from boards.abstracts.tripcode import Tripcode
5 from boards.abstracts.tripcode import Tripcode
6 from boards.models import PostImage, Attachment, KeyPair, GlobalId
6 from boards.models import Attachment, KeyPair, GlobalId
7 from boards.models.attachment import FILE_TYPES_IMAGE
7 from boards.models.base import Viewable
8 from boards.models.base import Viewable
8 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
9 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
9 from boards.models.post.manager import PostManager
10 from boards.models.post.manager import PostManager
10 from boards.utils import datetime_to_epoch
11 from boards.utils import datetime_to_epoch
11 from django.core.exceptions import ObjectDoesNotExist
12 from django.core.exceptions import ObjectDoesNotExist
12 from django.core.urlresolvers import reverse
13 from django.core.urlresolvers import reverse
13 from django.db import models
14 from django.db import models
14 from django.db.models import TextField, QuerySet, F
15 from django.db.models import TextField, QuerySet, F
15 from django.template.defaultfilters import truncatewords, striptags
16 from django.template.defaultfilters import truncatewords, striptags
16 from django.template.loader import render_to_string
17 from django.template.loader import render_to_string
17
18
18 CSS_CLS_HIDDEN_POST = 'hidden_post'
19 CSS_CLS_HIDDEN_POST = 'hidden_post'
19 CSS_CLS_DEAD_POST = 'dead_post'
20 CSS_CLS_DEAD_POST = 'dead_post'
20 CSS_CLS_ARCHIVE_POST = 'archive_post'
21 CSS_CLS_ARCHIVE_POST = 'archive_post'
21 CSS_CLS_POST = 'post'
22 CSS_CLS_POST = 'post'
22 CSS_CLS_MONOCHROME = 'monochrome'
23 CSS_CLS_MONOCHROME = 'monochrome'
23
24
24 TITLE_MAX_WORDS = 10
25 TITLE_MAX_WORDS = 10
25
26
26 APP_LABEL_BOARDS = 'boards'
27 APP_LABEL_BOARDS = 'boards'
27
28
28 BAN_REASON_AUTO = 'Auto'
29 BAN_REASON_AUTO = 'Auto'
29
30
30 IMAGE_THUMB_SIZE = (200, 150)
31
32 TITLE_MAX_LENGTH = 200
31 TITLE_MAX_LENGTH = 200
33
32
34 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
33 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
35 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
34 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
36 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
35 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
37 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
36 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
38
37
39 PARAMETER_TRUNCATED = 'truncated'
38 PARAMETER_TRUNCATED = 'truncated'
40 PARAMETER_TAG = 'tag'
39 PARAMETER_TAG = 'tag'
41 PARAMETER_OFFSET = 'offset'
40 PARAMETER_OFFSET = 'offset'
42 PARAMETER_DIFF_TYPE = 'type'
41 PARAMETER_DIFF_TYPE = 'type'
43 PARAMETER_CSS_CLASS = 'css_class'
42 PARAMETER_CSS_CLASS = 'css_class'
44 PARAMETER_THREAD = 'thread'
43 PARAMETER_THREAD = 'thread'
45 PARAMETER_IS_OPENING = 'is_opening'
44 PARAMETER_IS_OPENING = 'is_opening'
46 PARAMETER_POST = 'post'
45 PARAMETER_POST = 'post'
47 PARAMETER_OP_ID = 'opening_post_id'
46 PARAMETER_OP_ID = 'opening_post_id'
48 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
47 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
49 PARAMETER_REPLY_LINK = 'reply_link'
48 PARAMETER_REPLY_LINK = 'reply_link'
50 PARAMETER_NEED_OP_DATA = 'need_op_data'
49 PARAMETER_NEED_OP_DATA = 'need_op_data'
51
50
52 POST_VIEW_PARAMS = (
51 POST_VIEW_PARAMS = (
53 'need_op_data',
52 'need_op_data',
54 'reply_link',
53 'reply_link',
55 'need_open_link',
54 'need_open_link',
56 'truncated',
55 'truncated',
57 'mode_tree',
56 'mode_tree',
58 'perms',
57 'perms',
59 'tree_depth',
58 'tree_depth',
60 )
59 )
61
60
62
61
63 class Post(models.Model, Viewable):
62 class Post(models.Model, Viewable):
64 """A post is a message."""
63 """A post is a message."""
65
64
66 objects = PostManager()
65 objects = PostManager()
67
66
68 class Meta:
67 class Meta:
69 app_label = APP_LABEL_BOARDS
68 app_label = APP_LABEL_BOARDS
70 ordering = ('id',)
69 ordering = ('id',)
71
70
72 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
71 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
73 pub_time = models.DateTimeField()
72 pub_time = models.DateTimeField()
74 text = TextField(blank=True, null=True)
73 text = TextField(blank=True, null=True)
75 _text_rendered = TextField(blank=True, null=True, editable=False)
74 _text_rendered = TextField(blank=True, null=True, editable=False)
76
75
77 images = models.ManyToManyField(PostImage, null=True, blank=True,
78 related_name='post_images', db_index=True)
79 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
76 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
80 related_name='attachment_posts')
77 related_name='attachment_posts')
81
78
82 poster_ip = models.GenericIPAddressField()
79 poster_ip = models.GenericIPAddressField()
83
80
84 # TODO This field can be removed cause UID is used for update now
81 # TODO This field can be removed cause UID is used for update now
85 last_edit_time = models.DateTimeField()
82 last_edit_time = models.DateTimeField()
86
83
87 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
84 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
88 null=True,
85 null=True,
89 blank=True, related_name='refposts',
86 blank=True, related_name='refposts',
90 db_index=True)
87 db_index=True)
91 refmap = models.TextField(null=True, blank=True)
88 refmap = models.TextField(null=True, blank=True)
92 threads = models.ManyToManyField('Thread', db_index=True,
89 threads = models.ManyToManyField('Thread', db_index=True,
93 related_name='multi_replies')
90 related_name='multi_replies')
94 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
91 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
95
92
96 url = models.TextField()
93 url = models.TextField()
97 uid = models.TextField(db_index=True)
94 uid = models.TextField(db_index=True)
98
95
99 # Global ID with author key. If the message was downloaded from another
96 # Global ID with author key. If the message was downloaded from another
100 # server, this indicates the server.
97 # server, this indicates the server.
101 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
98 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
102 on_delete=models.CASCADE)
99 on_delete=models.CASCADE)
103
100
104 tripcode = models.CharField(max_length=50, blank=True, default='')
101 tripcode = models.CharField(max_length=50, blank=True, default='')
105 opening = models.BooleanField(db_index=True)
102 opening = models.BooleanField(db_index=True)
106 hidden = models.BooleanField(default=False)
103 hidden = models.BooleanField(default=False)
107 version = models.IntegerField(default=1)
104 version = models.IntegerField(default=1)
108
105
109 def __str__(self):
106 def __str__(self):
110 return 'P#{}/{}'.format(self.id, self.get_title())
107 return 'P#{}/{}'.format(self.id, self.get_title())
111
108
112 def get_title(self) -> str:
109 def get_title(self) -> str:
113 return self.title
110 return self.title
114
111
115 def get_title_or_text(self):
112 def get_title_or_text(self):
116 title = self.get_title()
113 title = self.get_title()
117 if not title:
114 if not title:
118 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
115 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
119
116
120 return title
117 return title
121
118
122 def build_refmap(self) -> None:
119 def build_refmap(self) -> None:
123 """
120 """
124 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
125 the server from recalculating the map on every post show.
122 the server from recalculating the map on every post show.
126 """
123 """
127
124
128 post_urls = [refpost.get_link_view()
125 post_urls = [refpost.get_link_view()
129 for refpost in self.referenced_posts.all()]
126 for refpost in self.referenced_posts.all()]
130
127
131 self.refmap = ', '.join(post_urls)
128 self.refmap = ', '.join(post_urls)
132
129
133 def is_referenced(self) -> bool:
130 def is_referenced(self) -> bool:
134 return self.refmap and len(self.refmap) > 0
131 return self.refmap and len(self.refmap) > 0
135
132
136 def is_opening(self) -> bool:
133 def is_opening(self) -> bool:
137 """
134 """
138 Checks if this is an opening post or just a reply.
135 Checks if this is an opening post or just a reply.
139 """
136 """
140
137
141 return self.opening
138 return self.opening
142
139
143 def get_absolute_url(self, thread=None):
140 def get_absolute_url(self, thread=None):
144 url = None
141 url = None
145
142
146 if thread is None:
143 if thread is None:
147 thread = self.get_thread()
144 thread = self.get_thread()
148
145
149 # Url is cached only for the "main" thread. When getting url
146 # Url is cached only for the "main" thread. When getting url
150 # for other threads, do it manually.
147 # for other threads, do it manually.
151 if self.url:
148 if self.url:
152 url = self.url
149 url = self.url
153
150
154 if url is None:
151 if url is None:
155 opening = self.is_opening()
152 opening = self.is_opening()
156 opening_id = self.id if opening else thread.get_opening_post_id()
153 opening_id = self.id if opening else thread.get_opening_post_id()
157 url = reverse('thread', kwargs={'post_id': opening_id})
154 url = reverse('thread', kwargs={'post_id': opening_id})
158 if not opening:
155 if not opening:
159 url += '#' + str(self.id)
156 url += '#' + str(self.id)
160
157
161 return url
158 return url
162
159
163 def get_thread(self):
160 def get_thread(self):
164 return self.thread
161 return self.thread
165
162
166 def get_thread_id(self):
163 def get_thread_id(self):
167 return self.thread_id
164 return self.thread_id
168
165
169 def get_threads(self) -> QuerySet:
166 def get_threads(self) -> QuerySet:
170 """
167 """
171 Gets post's thread.
168 Gets post's thread.
172 """
169 """
173
170
174 return self.threads
171 return self.threads
175
172
176 def _get_cache_key(self):
173 def _get_cache_key(self):
177 return [datetime_to_epoch(self.last_edit_time)]
174 return [datetime_to_epoch(self.last_edit_time)]
178
175
179 def get_view(self, *args, **kwargs) -> str:
176 def get_view(self, *args, **kwargs) -> str:
180 """
177 """
181 Renders post's HTML view. Some of the post params can be passed over
178 Renders post's HTML view. Some of the post params can be passed over
182 kwargs for the means of caching (if we view the thread, some params
179 kwargs for the means of caching (if we view the thread, some params
183 are same for every post and don't need to be computed over and over.
180 are same for every post and don't need to be computed over and over.
184 """
181 """
185
182
186 thread = self.get_thread()
183 thread = self.get_thread()
187
184
188 css_classes = [CSS_CLS_POST]
185 css_classes = [CSS_CLS_POST]
189 if thread.is_archived():
186 if thread.is_archived():
190 css_classes.append(CSS_CLS_ARCHIVE_POST)
187 css_classes.append(CSS_CLS_ARCHIVE_POST)
191 elif not thread.can_bump():
188 elif not thread.can_bump():
192 css_classes.append(CSS_CLS_DEAD_POST)
189 css_classes.append(CSS_CLS_DEAD_POST)
193 if self.is_hidden():
190 if self.is_hidden():
194 css_classes.append(CSS_CLS_HIDDEN_POST)
191 css_classes.append(CSS_CLS_HIDDEN_POST)
195 if thread.is_monochrome():
192 if thread.is_monochrome():
196 css_classes.append(CSS_CLS_MONOCHROME)
193 css_classes.append(CSS_CLS_MONOCHROME)
197
194
198 params = dict()
195 params = dict()
199 for param in POST_VIEW_PARAMS:
196 for param in POST_VIEW_PARAMS:
200 if param in kwargs:
197 if param in kwargs:
201 params[param] = kwargs[param]
198 params[param] = kwargs[param]
202
199
203 params.update({
200 params.update({
204 PARAMETER_POST: self,
201 PARAMETER_POST: self,
205 PARAMETER_IS_OPENING: self.is_opening(),
202 PARAMETER_IS_OPENING: self.is_opening(),
206 PARAMETER_THREAD: thread,
203 PARAMETER_THREAD: thread,
207 PARAMETER_CSS_CLASS: ' '.join(css_classes),
204 PARAMETER_CSS_CLASS: ' '.join(css_classes),
208 })
205 })
209
206
210 return render_to_string('boards/post.html', params)
207 return render_to_string('boards/post.html', params)
211
208
212 def get_search_view(self, *args, **kwargs):
209 def get_search_view(self, *args, **kwargs):
213 return self.get_view(need_op_data=True, *args, **kwargs)
210 return self.get_view(need_op_data=True, *args, **kwargs)
214
211
215 def get_first_image(self) -> PostImage:
212 def get_first_image(self) -> Attachment:
216 return self.images.earliest('id')
213 return self.attachments.filter(mimetype__in=FILE_TYPES_IMAGE).earliest('id')
217
214
218 def set_global_id(self, key_pair=None):
215 def set_global_id(self, key_pair=None):
219 """
216 """
220 Sets global id based on the given key pair. If no key pair is given,
217 Sets global id based on the given key pair. If no key pair is given,
221 default one is used.
218 default one is used.
222 """
219 """
223
220
224 if key_pair:
221 if key_pair:
225 key = key_pair
222 key = key_pair
226 else:
223 else:
227 try:
224 try:
228 key = KeyPair.objects.get(primary=True)
225 key = KeyPair.objects.get(primary=True)
229 except KeyPair.DoesNotExist:
226 except KeyPair.DoesNotExist:
230 # Do not update the global id because there is no key defined
227 # Do not update the global id because there is no key defined
231 return
228 return
232 global_id = GlobalId(key_type=key.key_type,
229 global_id = GlobalId(key_type=key.key_type,
233 key=key.public_key,
230 key=key.public_key,
234 local_id=self.id)
231 local_id=self.id)
235 global_id.save()
232 global_id.save()
236
233
237 self.global_id = global_id
234 self.global_id = global_id
238
235
239 self.save(update_fields=['global_id'])
236 self.save(update_fields=['global_id'])
240
237
241 def get_pub_time_str(self):
238 def get_pub_time_str(self):
242 return str(self.pub_time)
239 return str(self.pub_time)
243
240
244 def get_replied_ids(self):
241 def get_replied_ids(self):
245 """
242 """
246 Gets ID list of the posts that this post replies.
243 Gets ID list of the posts that this post replies.
247 """
244 """
248
245
249 raw_text = self.get_raw_text()
246 raw_text = self.get_raw_text()
250
247
251 local_replied = REGEX_REPLY.findall(raw_text)
248 local_replied = REGEX_REPLY.findall(raw_text)
252 global_replied = []
249 global_replied = []
253 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
250 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
254 key_type = match[0]
251 key_type = match[0]
255 key = match[1]
252 key = match[1]
256 local_id = match[2]
253 local_id = match[2]
257
254
258 try:
255 try:
259 global_id = GlobalId.objects.get(key_type=key_type,
256 global_id = GlobalId.objects.get(key_type=key_type,
260 key=key, local_id=local_id)
257 key=key, local_id=local_id)
261 for post in Post.objects.filter(global_id=global_id).only('id'):
258 for post in Post.objects.filter(global_id=global_id).only('id'):
262 global_replied.append(post.id)
259 global_replied.append(post.id)
263 except GlobalId.DoesNotExist:
260 except GlobalId.DoesNotExist:
264 pass
261 pass
265 return local_replied + global_replied
262 return local_replied + global_replied
266
263
267 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
264 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
268 include_last_update=False) -> str:
265 include_last_update=False) -> str:
269 """
266 """
270 Gets post HTML or JSON data that can be rendered on a page or used by
267 Gets post HTML or JSON data that can be rendered on a page or used by
271 API.
268 API.
272 """
269 """
273
270
274 return get_exporter(format_type).export(self, request,
271 return get_exporter(format_type).export(self, request,
275 include_last_update)
272 include_last_update)
276
273
277 def notify_clients(self, recursive=True):
274 def notify_clients(self, recursive=True):
278 """
275 """
279 Sends post HTML data to the thread web socket.
276 Sends post HTML data to the thread web socket.
280 """
277 """
281
278
282 if not settings.get_bool('External', 'WebsocketsEnabled'):
279 if not settings.get_bool('External', 'WebsocketsEnabled'):
283 return
280 return
284
281
285 thread_ids = list()
282 thread_ids = list()
286 for thread in self.get_threads().all():
283 for thread in self.get_threads().all():
287 thread_ids.append(thread.id)
284 thread_ids.append(thread.id)
288
285
289 thread.notify_clients()
286 thread.notify_clients()
290
287
291 if recursive:
288 if recursive:
292 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
289 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
293 post_id = reply_number.group(1)
290 post_id = reply_number.group(1)
294
291
295 try:
292 try:
296 ref_post = Post.objects.get(id=post_id)
293 ref_post = Post.objects.get(id=post_id)
297
294
298 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
295 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
299 # If post is in this thread, its thread was already notified.
296 # If post is in this thread, its thread was already notified.
300 # Otherwise, notify its thread separately.
297 # Otherwise, notify its thread separately.
301 ref_post.notify_clients(recursive=False)
298 ref_post.notify_clients(recursive=False)
302 except ObjectDoesNotExist:
299 except ObjectDoesNotExist:
303 pass
300 pass
304
301
305 def build_url(self):
302 def build_url(self):
306 self.url = self.get_absolute_url()
303 self.url = self.get_absolute_url()
307 self.save(update_fields=['url'])
304 self.save(update_fields=['url'])
308
305
309 def save(self, force_insert=False, force_update=False, using=None,
306 def save(self, force_insert=False, force_update=False, using=None,
310 update_fields=None):
307 update_fields=None):
311 new_post = self.id is None
308 new_post = self.id is None
312
309
313 self.uid = str(uuid.uuid4())
310 self.uid = str(uuid.uuid4())
314 if update_fields is not None and 'uid' not in update_fields:
311 if update_fields is not None and 'uid' not in update_fields:
315 update_fields += ['uid']
312 update_fields += ['uid']
316
313
317 if not new_post:
314 if not new_post:
318 for thread in self.get_threads().all():
315 for thread in self.get_threads().all():
319 thread.last_edit_time = self.last_edit_time
316 thread.last_edit_time = self.last_edit_time
320
317
321 thread.save(update_fields=['last_edit_time', 'status'])
318 thread.save(update_fields=['last_edit_time', 'status'])
322
319
323 super().save(force_insert, force_update, using, update_fields)
320 super().save(force_insert, force_update, using, update_fields)
324
321
325 if self.url is None:
322 if self.url is None:
326 self.build_url()
323 self.build_url()
327
324
328 def get_text(self) -> str:
325 def get_text(self) -> str:
329 return self._text_rendered
326 return self._text_rendered
330
327
331 def get_raw_text(self) -> str:
328 def get_raw_text(self) -> str:
332 return self.text
329 return self.text
333
330
334 def get_sync_text(self) -> str:
331 def get_sync_text(self) -> str:
335 """
332 """
336 Returns text applicable for sync. It has absolute post reflinks.
333 Returns text applicable for sync. It has absolute post reflinks.
337 """
334 """
338
335
339 replacements = dict()
336 replacements = dict()
340 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
337 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
341 try:
338 try:
342 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
339 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
343 replacements[post_id] = absolute_post_id
340 replacements[post_id] = absolute_post_id
344 except Post.DoesNotExist:
341 except Post.DoesNotExist:
345 pass
342 pass
346
343
347 text = self.get_raw_text() or ''
344 text = self.get_raw_text() or ''
348 for key in replacements:
345 for key in replacements:
349 text = text.replace('[post]{}[/post]'.format(key),
346 text = text.replace('[post]{}[/post]'.format(key),
350 '[post]{}[/post]'.format(replacements[key]))
347 '[post]{}[/post]'.format(replacements[key]))
351 text = text.replace('\r\n', '\n').replace('\r', '\n')
348 text = text.replace('\r\n', '\n').replace('\r', '\n')
352
349
353 return text
350 return text
354
351
355 def connect_threads(self, opening_posts):
352 def connect_threads(self, opening_posts):
356 for opening_post in opening_posts:
353 for opening_post in opening_posts:
357 threads = opening_post.get_threads().all()
354 threads = opening_post.get_threads().all()
358 for thread in threads:
355 for thread in threads:
359 if thread.can_bump():
356 if thread.can_bump():
360 thread.update_bump_status()
357 thread.update_bump_status()
361
358
362 thread.last_edit_time = self.last_edit_time
359 thread.last_edit_time = self.last_edit_time
363 thread.save(update_fields=['last_edit_time', 'status'])
360 thread.save(update_fields=['last_edit_time', 'status'])
364 self.threads.add(opening_post.get_thread())
361 self.threads.add(opening_post.get_thread())
365
362
366 def get_tripcode(self):
363 def get_tripcode(self):
367 if self.tripcode:
364 if self.tripcode:
368 return Tripcode(self.tripcode)
365 return Tripcode(self.tripcode)
369
366
370 def get_link_view(self):
367 def get_link_view(self):
371 """
368 """
372 Gets view of a reflink to the post.
369 Gets view of a reflink to the post.
373 """
370 """
374 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
371 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
375 self.id)
372 self.id)
376 if self.is_opening():
373 if self.is_opening():
377 result = '<b>{}</b>'.format(result)
374 result = '<b>{}</b>'.format(result)
378
375
379 return result
376 return result
380
377
381 def is_hidden(self) -> bool:
378 def is_hidden(self) -> bool:
382 return self.hidden
379 return self.hidden
383
380
384 def set_hidden(self, hidden):
381 def set_hidden(self, hidden):
385 self.hidden = hidden
382 self.hidden = hidden
386
383
387 def increment_version(self):
384 def increment_version(self):
388 self.version = F('version') + 1
385 self.version = F('version') + 1
389
386
390 def clear_cache(self):
387 def clear_cache(self):
391 """
388 """
392 Clears sync data (content cache, signatures etc).
389 Clears sync data (content cache, signatures etc).
393 """
390 """
394 global_id = self.global_id
391 global_id = self.global_id
395 if global_id is not None and global_id.is_local()\
392 if global_id is not None and global_id.is_local()\
396 and global_id.content is not None:
393 and global_id.content is not None:
397 global_id.clear_cache()
394 global_id.clear_cache()
@@ -1,193 +1,181 b''
1 import logging
1 import logging
2
2
3 from datetime import datetime, timedelta, date
3 from datetime import datetime, timedelta, date
4 from datetime import time as dtime
4 from datetime import time as dtime
5
5
6 from django.db import models, transaction
6 from django.db import models, transaction
7 from django.utils import timezone
7 from django.utils import timezone
8 from django.dispatch import Signal
8 from django.dispatch import Signal
9
9
10 import boards
10 import boards
11
11
12 from boards.models.user import Ban
12 from boards.models.user import Ban
13 from boards.mdx_neboard import Parser
13 from boards.mdx_neboard import Parser
14 from boards.models import PostImage, Attachment
14 from boards.models import Attachment
15 from boards import utils
15 from boards import utils
16
16
17 __author__ = 'neko259'
17 __author__ = 'neko259'
18
18
19 IMAGE_TYPES = (
20 'jpeg',
21 'jpg',
22 'png',
23 'bmp',
24 'gif',
25 )
26
27 POSTS_PER_DAY_RANGE = 7
19 POSTS_PER_DAY_RANGE = 7
28 NO_IP = '0.0.0.0'
20 NO_IP = '0.0.0.0'
29
21
30
22
31 post_import_deps = Signal()
23 post_import_deps = Signal()
32
24
33
25
34 class PostManager(models.Manager):
26 class PostManager(models.Manager):
35 @transaction.atomic
27 @transaction.atomic
36 def create_post(self, title: str, text: str, file=None, thread=None,
28 def create_post(self, title: str, text: str, file=None, thread=None,
37 ip=NO_IP, tags: list=None, opening_posts: list=None,
29 ip=NO_IP, tags: list=None, opening_posts: list=None,
38 tripcode='', monochrome=False, images=[]):
30 tripcode='', monochrome=False, images=[]):
39 """
31 """
40 Creates new post
32 Creates new post
41 """
33 """
42
34
43 if thread is not None and thread.is_archived():
35 if thread is not None and thread.is_archived():
44 raise Exception('Cannot post into an archived thread')
36 raise Exception('Cannot post into an archived thread')
45
37
46 if not utils.is_anonymous_mode():
38 if not utils.is_anonymous_mode():
47 is_banned = Ban.objects.filter(ip=ip).exists()
39 is_banned = Ban.objects.filter(ip=ip).exists()
48 else:
40 else:
49 is_banned = False
41 is_banned = False
50
42
51 # TODO Raise specific exception and catch it in the views
43 # TODO Raise specific exception and catch it in the views
52 if is_banned:
44 if is_banned:
53 raise Exception("This user is banned")
45 raise Exception("This user is banned")
54
46
55 if not tags:
47 if not tags:
56 tags = []
48 tags = []
57 if not opening_posts:
49 if not opening_posts:
58 opening_posts = []
50 opening_posts = []
59
51
60 posting_time = timezone.now()
52 posting_time = timezone.now()
61 new_thread = False
53 new_thread = False
62 if not thread:
54 if not thread:
63 thread = boards.models.thread.Thread.objects.create(
55 thread = boards.models.thread.Thread.objects.create(
64 bump_time=posting_time, last_edit_time=posting_time,
56 bump_time=posting_time, last_edit_time=posting_time,
65 monochrome=monochrome)
57 monochrome=monochrome)
66 list(map(thread.tags.add, tags))
58 list(map(thread.tags.add, tags))
67 boards.models.thread.Thread.objects.process_oldest_threads()
59 boards.models.thread.Thread.objects.process_oldest_threads()
68 new_thread = True
60 new_thread = True
69
61
70 pre_text = Parser().preparse(text)
62 pre_text = Parser().preparse(text)
71
63
72 post = self.create(title=title,
64 post = self.create(title=title,
73 text=pre_text,
65 text=pre_text,
74 pub_time=posting_time,
66 pub_time=posting_time,
75 poster_ip=ip,
67 poster_ip=ip,
76 thread=thread,
68 thread=thread,
77 last_edit_time=posting_time,
69 last_edit_time=posting_time,
78 tripcode=tripcode,
70 tripcode=tripcode,
79 opening=new_thread)
71 opening=new_thread)
80 post.threads.add(thread)
72 post.threads.add(thread)
81
73
82 logger = logging.getLogger('boards.post.create')
74 logger = logging.getLogger('boards.post.create')
83
75
84 logger.info('Created post [{}] with text [{}] by {}'.format(post,
76 logger.info('Created post [{}] with text [{}] by {}'.format(post,
85 post.get_text(),post.poster_ip))
77 post.get_text(),post.poster_ip))
86
78
87 if file:
79 if file:
88 self._add_file_to_post(file, post)
80 self._add_file_to_post(file, post)
89 for image in images:
81 for image in images:
90 post.images.add(image)
82 post.images.add(image)
91
83
92 post.connect_threads(opening_posts)
84 post.connect_threads(opening_posts)
93 post.set_global_id()
85 post.set_global_id()
94
86
95 # Thread needs to be bumped only when the post is already created
87 # Thread needs to be bumped only when the post is already created
96 if not new_thread:
88 if not new_thread:
97 thread.last_edit_time = posting_time
89 thread.last_edit_time = posting_time
98 thread.bump()
90 thread.bump()
99 thread.save()
91 thread.save()
100
92
101 return post
93 return post
102
94
103 def delete_posts_by_ip(self, ip):
95 def delete_posts_by_ip(self, ip):
104 """
96 """
105 Deletes all posts of the author with same IP
97 Deletes all posts of the author with same IP
106 """
98 """
107
99
108 posts = self.filter(poster_ip=ip)
100 posts = self.filter(poster_ip=ip)
109 for post in posts:
101 for post in posts:
110 post.delete()
102 post.delete()
111
103
112 @utils.cached_result()
104 @utils.cached_result()
113 def get_posts_per_day(self) -> float:
105 def get_posts_per_day(self) -> float:
114 """
106 """
115 Gets average count of posts per day for the last 7 days
107 Gets average count of posts per day for the last 7 days
116 """
108 """
117
109
118 day_end = date.today()
110 day_end = date.today()
119 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
111 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
120
112
121 day_time_start = timezone.make_aware(datetime.combine(
113 day_time_start = timezone.make_aware(datetime.combine(
122 day_start, dtime()), timezone.get_current_timezone())
114 day_start, dtime()), timezone.get_current_timezone())
123 day_time_end = timezone.make_aware(datetime.combine(
115 day_time_end = timezone.make_aware(datetime.combine(
124 day_end, dtime()), timezone.get_current_timezone())
116 day_end, dtime()), timezone.get_current_timezone())
125
117
126 posts_per_period = float(self.filter(
118 posts_per_period = float(self.filter(
127 pub_time__lte=day_time_end,
119 pub_time__lte=day_time_end,
128 pub_time__gte=day_time_start).count())
120 pub_time__gte=day_time_start).count())
129
121
130 ppd = posts_per_period / POSTS_PER_DAY_RANGE
122 ppd = posts_per_period / POSTS_PER_DAY_RANGE
131
123
132 return ppd
124 return ppd
133
125
134 @transaction.atomic
126 @transaction.atomic
135 def import_post(self, title: str, text: str, pub_time: str, global_id,
127 def import_post(self, title: str, text: str, pub_time: str, global_id,
136 opening_post=None, tags=list(), files=list(),
128 opening_post=None, tags=list(), files=list(),
137 tripcode=None, version=1):
129 tripcode=None, version=1):
138 is_opening = opening_post is None
130 is_opening = opening_post is None
139 if is_opening:
131 if is_opening:
140 thread = boards.models.thread.Thread.objects.create(
132 thread = boards.models.thread.Thread.objects.create(
141 bump_time=pub_time, last_edit_time=pub_time)
133 bump_time=pub_time, last_edit_time=pub_time)
142 list(map(thread.tags.add, tags))
134 list(map(thread.tags.add, tags))
143 else:
135 else:
144 thread = opening_post.get_thread()
136 thread = opening_post.get_thread()
145
137
146 post = self.create(title=title,
138 post = self.create(title=title,
147 text=text,
139 text=text,
148 pub_time=pub_time,
140 pub_time=pub_time,
149 poster_ip=NO_IP,
141 poster_ip=NO_IP,
150 last_edit_time=pub_time,
142 last_edit_time=pub_time,
151 global_id=global_id,
143 global_id=global_id,
152 opening=is_opening,
144 opening=is_opening,
153 thread=thread,
145 thread=thread,
154 tripcode=tripcode,
146 tripcode=tripcode,
155 version=version)
147 version=version)
156
148
157 for file in files:
149 for file in files:
158 self._add_file_to_post(file, post)
150 self._add_file_to_post(file, post)
159
151
160 post.threads.add(thread)
152 post.threads.add(thread)
161
153
162 url_to_post = '[post]{}[/post]'.format(str(global_id))
154 url_to_post = '[post]{}[/post]'.format(str(global_id))
163 replies = self.filter(text__contains=url_to_post)
155 replies = self.filter(text__contains=url_to_post)
164 for reply in replies:
156 for reply in replies:
165 post_import_deps.send(reply.__class__)
157 post_import_deps.send(reply.__class__)
166
158
167 @transaction.atomic
159 @transaction.atomic
168 def update_post(self, post, title: str, text: str, pub_time: str,
160 def update_post(self, post, title: str, text: str, pub_time: str,
169 tags=list(), files=list(), tripcode=None, version=1):
161 tags=list(), files=list(), tripcode=None, version=1):
170 post.title = title
162 post.title = title
171 post.text = text
163 post.text = text
172 post.pub_time = pub_time
164 post.pub_time = pub_time
173 post.tripcode = tripcode
165 post.tripcode = tripcode
174 post.version = version
166 post.version = version
175 post.save()
167 post.save()
176
168
177 post.clear_cache()
169 post.clear_cache()
178
170
179 post.images.clear()
171 post.images.clear()
180 post.attachments.clear()
172 post.attachments.clear()
181 for file in files:
173 for file in files:
182 self._add_file_to_post(file, post)
174 self._add_file_to_post(file, post)
183
175
184 thread = post.get_thread()
176 thread = post.get_thread()
185 thread.tags.clear()
177 thread.tags.clear()
186 list(map(thread.tags.add, tags))
178 list(map(thread.tags.add, tags))
187
179
188 def _add_file_to_post(self, file, post):
180 def _add_file_to_post(self, file, post):
189 file_type = file.name.split('.')[-1].lower()
181 post.attachments.add(Attachment.objects.create_with_hash(file))
190 if file_type in IMAGE_TYPES:
191 post.images.add(PostImage.objects.create_with_hash(file))
192 else:
193 post.attachments.add(Attachment.objects.create_with_hash(file))
@@ -1,310 +1,301 b''
1 import xml.etree.ElementTree as et
1 import xml.etree.ElementTree as et
2
2
3 from boards.models.attachment.downloaders import download
3 from boards.models.attachment.downloaders import download
4 from boards.utils import get_file_mimetype, get_file_hash
4 from boards.utils import get_file_mimetype, get_file_hash
5 from django.db import transaction
5 from django.db import transaction
6 from boards.models import KeyPair, GlobalId, Signature, Post, Tag
6 from boards.models import KeyPair, GlobalId, Signature, Post, Tag
7
7
8 EXCEPTION_NODE = 'Sync node returned an error: {}.'
8 EXCEPTION_NODE = 'Sync node returned an error: {}.'
9 EXCEPTION_OP = 'Load the OP first.'
9 EXCEPTION_OP = 'Load the OP first.'
10 EXCEPTION_DOWNLOAD = 'File was not downloaded.'
10 EXCEPTION_DOWNLOAD = 'File was not downloaded.'
11 EXCEPTION_HASH = 'File hash does not match attachment hash.'
11 EXCEPTION_HASH = 'File hash does not match attachment hash.'
12 EXCEPTION_SIGNATURE = 'Invalid model signature for {}.'
12 EXCEPTION_SIGNATURE = 'Invalid model signature for {}.'
13 EXCEPTION_AUTHOR_SIGNATURE = 'Model {} has no author signature.'
13 EXCEPTION_AUTHOR_SIGNATURE = 'Model {} has no author signature.'
14 ENCODING_UNICODE = 'unicode'
14 ENCODING_UNICODE = 'unicode'
15
15
16 TAG_MODEL = 'model'
16 TAG_MODEL = 'model'
17 TAG_REQUEST = 'request'
17 TAG_REQUEST = 'request'
18 TAG_RESPONSE = 'response'
18 TAG_RESPONSE = 'response'
19 TAG_ID = 'id'
19 TAG_ID = 'id'
20 TAG_STATUS = 'status'
20 TAG_STATUS = 'status'
21 TAG_MODELS = 'models'
21 TAG_MODELS = 'models'
22 TAG_TITLE = 'title'
22 TAG_TITLE = 'title'
23 TAG_TEXT = 'text'
23 TAG_TEXT = 'text'
24 TAG_THREAD = 'thread'
24 TAG_THREAD = 'thread'
25 TAG_PUB_TIME = 'pub-time'
25 TAG_PUB_TIME = 'pub-time'
26 TAG_SIGNATURES = 'signatures'
26 TAG_SIGNATURES = 'signatures'
27 TAG_SIGNATURE = 'signature'
27 TAG_SIGNATURE = 'signature'
28 TAG_CONTENT = 'content'
28 TAG_CONTENT = 'content'
29 TAG_ATTACHMENTS = 'attachments'
29 TAG_ATTACHMENTS = 'attachments'
30 TAG_ATTACHMENT = 'attachment'
30 TAG_ATTACHMENT = 'attachment'
31 TAG_TAGS = 'tags'
31 TAG_TAGS = 'tags'
32 TAG_TAG = 'tag'
32 TAG_TAG = 'tag'
33 TAG_ATTACHMENT_REFS = 'attachment-refs'
33 TAG_ATTACHMENT_REFS = 'attachment-refs'
34 TAG_ATTACHMENT_REF = 'attachment-ref'
34 TAG_ATTACHMENT_REF = 'attachment-ref'
35 TAG_TRIPCODE = 'tripcode'
35 TAG_TRIPCODE = 'tripcode'
36 TAG_VERSION = 'version'
36 TAG_VERSION = 'version'
37
37
38 TYPE_GET = 'get'
38 TYPE_GET = 'get'
39
39
40 ATTR_VERSION = 'version'
40 ATTR_VERSION = 'version'
41 ATTR_TYPE = 'type'
41 ATTR_TYPE = 'type'
42 ATTR_NAME = 'name'
42 ATTR_NAME = 'name'
43 ATTR_VALUE = 'value'
43 ATTR_VALUE = 'value'
44 ATTR_MIMETYPE = 'mimetype'
44 ATTR_MIMETYPE = 'mimetype'
45 ATTR_KEY = 'key'
45 ATTR_KEY = 'key'
46 ATTR_REF = 'ref'
46 ATTR_REF = 'ref'
47 ATTR_URL = 'url'
47 ATTR_URL = 'url'
48 ATTR_ID_TYPE = 'id-type'
48 ATTR_ID_TYPE = 'id-type'
49
49
50 ID_TYPE_MD5 = 'md5'
50 ID_TYPE_MD5 = 'md5'
51
51
52 STATUS_SUCCESS = 'success'
52 STATUS_SUCCESS = 'success'
53
53
54
54
55 class SyncException(Exception):
55 class SyncException(Exception):
56 pass
56 pass
57
57
58
58
59 class SyncManager:
59 class SyncManager:
60 @staticmethod
60 @staticmethod
61 def generate_response_get(model_list: list):
61 def generate_response_get(model_list: list):
62 response = et.Element(TAG_RESPONSE)
62 response = et.Element(TAG_RESPONSE)
63
63
64 status = et.SubElement(response, TAG_STATUS)
64 status = et.SubElement(response, TAG_STATUS)
65 status.text = STATUS_SUCCESS
65 status.text = STATUS_SUCCESS
66
66
67 models = et.SubElement(response, TAG_MODELS)
67 models = et.SubElement(response, TAG_MODELS)
68
68
69 for post in model_list:
69 for post in model_list:
70 model = et.SubElement(models, TAG_MODEL)
70 model = et.SubElement(models, TAG_MODEL)
71 model.set(ATTR_NAME, 'post')
71 model.set(ATTR_NAME, 'post')
72
72
73 global_id = post.global_id
73 global_id = post.global_id
74
74
75 images = post.images.all()
76 attachments = post.attachments.all()
75 attachments = post.attachments.all()
77 if global_id.content:
76 if global_id.content:
78 model.append(et.fromstring(global_id.content))
77 model.append(et.fromstring(global_id.content))
79 if len(images) > 0 or len(attachments) > 0:
78 if len(attachments) > 0:
80 attachment_refs = et.SubElement(model, TAG_ATTACHMENT_REFS)
79 attachment_refs = et.SubElement(model, TAG_ATTACHMENT_REFS)
81 for image in images:
82 SyncManager._attachment_to_xml(
83 None, attachment_refs, image.image.file,
84 image.hash, image.image.url)
85 for file in attachments:
80 for file in attachments:
86 SyncManager._attachment_to_xml(
81 SyncManager._attachment_to_xml(
87 None, attachment_refs, file.file.file,
82 None, attachment_refs, file.file.file,
88 file.hash, file.file.url)
83 file.hash, file.file.url)
89 else:
84 else:
90 content_tag = et.SubElement(model, TAG_CONTENT)
85 content_tag = et.SubElement(model, TAG_CONTENT)
91
86
92 tag_id = et.SubElement(content_tag, TAG_ID)
87 tag_id = et.SubElement(content_tag, TAG_ID)
93 global_id.to_xml_element(tag_id)
88 global_id.to_xml_element(tag_id)
94
89
95 title = et.SubElement(content_tag, TAG_TITLE)
90 title = et.SubElement(content_tag, TAG_TITLE)
96 title.text = post.title
91 title.text = post.title
97
92
98 text = et.SubElement(content_tag, TAG_TEXT)
93 text = et.SubElement(content_tag, TAG_TEXT)
99 text.text = post.get_sync_text()
94 text.text = post.get_sync_text()
100
95
101 thread = post.get_thread()
96 thread = post.get_thread()
102 if post.is_opening():
97 if post.is_opening():
103 tag_tags = et.SubElement(content_tag, TAG_TAGS)
98 tag_tags = et.SubElement(content_tag, TAG_TAGS)
104 for tag in thread.get_tags():
99 for tag in thread.get_tags():
105 tag_tag = et.SubElement(tag_tags, TAG_TAG)
100 tag_tag = et.SubElement(tag_tags, TAG_TAG)
106 tag_tag.text = tag.name
101 tag_tag.text = tag.name
107 else:
102 else:
108 tag_thread = et.SubElement(content_tag, TAG_THREAD)
103 tag_thread = et.SubElement(content_tag, TAG_THREAD)
109 thread_id = et.SubElement(tag_thread, TAG_ID)
104 thread_id = et.SubElement(tag_thread, TAG_ID)
110 thread.get_opening_post().global_id.to_xml_element(thread_id)
105 thread.get_opening_post().global_id.to_xml_element(thread_id)
111
106
112 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
107 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
113 pub_time.text = str(post.get_pub_time_str())
108 pub_time.text = str(post.get_pub_time_str())
114
109
115 if post.tripcode:
110 if post.tripcode:
116 tripcode = et.SubElement(content_tag, TAG_TRIPCODE)
111 tripcode = et.SubElement(content_tag, TAG_TRIPCODE)
117 tripcode.text = post.tripcode
112 tripcode.text = post.tripcode
118
113
119 if len(images) > 0 or len(attachments) > 0:
114 if len(attachments) > 0:
120 attachments_tag = et.SubElement(content_tag, TAG_ATTACHMENTS)
115 attachments_tag = et.SubElement(content_tag, TAG_ATTACHMENTS)
121 attachment_refs = et.SubElement(model, TAG_ATTACHMENT_REFS)
116 attachment_refs = et.SubElement(model, TAG_ATTACHMENT_REFS)
122
117
123 for image in images:
124 SyncManager._attachment_to_xml(
125 attachments_tag, attachment_refs, image.image.file,
126 image.hash, image.image.url)
127 for file in attachments:
118 for file in attachments:
128 SyncManager._attachment_to_xml(
119 SyncManager._attachment_to_xml(
129 attachments_tag, attachment_refs, file.file.file,
120 attachments_tag, attachment_refs, file.file.file,
130 file.hash, file.file.url)
121 file.hash, file.file.url)
131 version_tag = et.SubElement(content_tag, TAG_VERSION)
122 version_tag = et.SubElement(content_tag, TAG_VERSION)
132 version_tag.text = str(post.version)
123 version_tag.text = str(post.version)
133
124
134 global_id.content = et.tostring(content_tag, ENCODING_UNICODE)
125 global_id.content = et.tostring(content_tag, ENCODING_UNICODE)
135 global_id.save()
126 global_id.save()
136
127
137 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
128 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
138 post_signatures = global_id.signature_set.all()
129 post_signatures = global_id.signature_set.all()
139 if post_signatures:
130 if post_signatures:
140 signatures = post_signatures
131 signatures = post_signatures
141 else:
132 else:
142 key = KeyPair.objects.get(public_key=global_id.key)
133 key = KeyPair.objects.get(public_key=global_id.key)
143 signature = Signature(
134 signature = Signature(
144 key_type=key.key_type,
135 key_type=key.key_type,
145 key=key.public_key,
136 key=key.public_key,
146 signature=key.sign(global_id.content),
137 signature=key.sign(global_id.content),
147 global_id=global_id,
138 global_id=global_id,
148 )
139 )
149 signature.save()
140 signature.save()
150 signatures = [signature]
141 signatures = [signature]
151 for signature in signatures:
142 for signature in signatures:
152 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
143 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
153 signature_tag.set(ATTR_TYPE, signature.key_type)
144 signature_tag.set(ATTR_TYPE, signature.key_type)
154 signature_tag.set(ATTR_VALUE, signature.signature)
145 signature_tag.set(ATTR_VALUE, signature.signature)
155 signature_tag.set(ATTR_KEY, signature.key)
146 signature_tag.set(ATTR_KEY, signature.key)
156
147
157 return et.tostring(response, ENCODING_UNICODE)
148 return et.tostring(response, ENCODING_UNICODE)
158
149
159 @staticmethod
150 @staticmethod
160 @transaction.atomic
151 @transaction.atomic
161 def parse_response_get(response_xml, hostname):
152 def parse_response_get(response_xml, hostname):
162 tag_root = et.fromstring(response_xml)
153 tag_root = et.fromstring(response_xml)
163 tag_status = tag_root.find(TAG_STATUS)
154 tag_status = tag_root.find(TAG_STATUS)
164 if STATUS_SUCCESS == tag_status.text:
155 if STATUS_SUCCESS == tag_status.text:
165 tag_models = tag_root.find(TAG_MODELS)
156 tag_models = tag_root.find(TAG_MODELS)
166 for tag_model in tag_models:
157 for tag_model in tag_models:
167 tag_content = tag_model.find(TAG_CONTENT)
158 tag_content = tag_model.find(TAG_CONTENT)
168
159
169 content_str = et.tostring(tag_content, ENCODING_UNICODE)
160 content_str = et.tostring(tag_content, ENCODING_UNICODE)
170
161
171 tag_id = tag_content.find(TAG_ID)
162 tag_id = tag_content.find(TAG_ID)
172 global_id, exists = GlobalId.from_xml_element(tag_id)
163 global_id, exists = GlobalId.from_xml_element(tag_id)
173 signatures = SyncManager._verify_model(global_id, content_str, tag_model)
164 signatures = SyncManager._verify_model(global_id, content_str, tag_model)
174
165
175 version = int(tag_content.find(TAG_VERSION).text)
166 version = int(tag_content.find(TAG_VERSION).text)
176 is_old = exists and global_id.post.version < version
167 is_old = exists and global_id.post.version < version
177 if exists and not is_old:
168 if exists and not is_old:
178 print('Post with same ID exists and is up to date.')
169 print('Post with same ID exists and is up to date.')
179 else:
170 else:
180 global_id.content = content_str
171 global_id.content = content_str
181 global_id.save()
172 global_id.save()
182 for signature in signatures:
173 for signature in signatures:
183 signature.global_id = global_id
174 signature.global_id = global_id
184 signature.save()
175 signature.save()
185
176
186 title = tag_content.find(TAG_TITLE).text or ''
177 title = tag_content.find(TAG_TITLE).text or ''
187 text = tag_content.find(TAG_TEXT).text or ''
178 text = tag_content.find(TAG_TEXT).text or ''
188 pub_time = tag_content.find(TAG_PUB_TIME).text
179 pub_time = tag_content.find(TAG_PUB_TIME).text
189 tripcode_tag = tag_content.find(TAG_TRIPCODE)
180 tripcode_tag = tag_content.find(TAG_TRIPCODE)
190 if tripcode_tag is not None:
181 if tripcode_tag is not None:
191 tripcode = tripcode_tag.text or ''
182 tripcode = tripcode_tag.text or ''
192 else:
183 else:
193 tripcode = ''
184 tripcode = ''
194
185
195 thread = tag_content.find(TAG_THREAD)
186 thread = tag_content.find(TAG_THREAD)
196 tags = []
187 tags = []
197 if thread:
188 if thread:
198 thread_id = thread.find(TAG_ID)
189 thread_id = thread.find(TAG_ID)
199 op_global_id, exists = GlobalId.from_xml_element(thread_id)
190 op_global_id, exists = GlobalId.from_xml_element(thread_id)
200 if exists:
191 if exists:
201 opening_post = Post.objects.get(global_id=op_global_id)
192 opening_post = Post.objects.get(global_id=op_global_id)
202 else:
193 else:
203 raise SyncException(EXCEPTION_OP)
194 raise SyncException(EXCEPTION_OP)
204 else:
195 else:
205 opening_post = None
196 opening_post = None
206 tag_tags = tag_content.find(TAG_TAGS)
197 tag_tags = tag_content.find(TAG_TAGS)
207 for tag_tag in tag_tags:
198 for tag_tag in tag_tags:
208 tag, created = Tag.objects.get_or_create(
199 tag, created = Tag.objects.get_or_create(
209 name=tag_tag.text)
200 name=tag_tag.text)
210 tags.append(tag)
201 tags.append(tag)
211
202
212 # TODO Check that the replied posts are already present
203 # TODO Check that the replied posts are already present
213 # before adding new ones
204 # before adding new ones
214
205
215 files = []
206 files = []
216 tag_attachments = tag_content.find(TAG_ATTACHMENTS) or list()
207 tag_attachments = tag_content.find(TAG_ATTACHMENTS) or list()
217 tag_refs = tag_model.find(TAG_ATTACHMENT_REFS)
208 tag_refs = tag_model.find(TAG_ATTACHMENT_REFS)
218 for attachment in tag_attachments:
209 for attachment in tag_attachments:
219 tag_ref = tag_refs.find("{}[@ref='{}']".format(
210 tag_ref = tag_refs.find("{}[@ref='{}']".format(
220 TAG_ATTACHMENT_REF, attachment.text))
211 TAG_ATTACHMENT_REF, attachment.text))
221 url = tag_ref.get(ATTR_URL)
212 url = tag_ref.get(ATTR_URL)
222 attached_file = download(hostname + url)
213 attached_file = download(hostname + url)
223 if attached_file is None:
214 if attached_file is None:
224 raise SyncException(EXCEPTION_DOWNLOAD)
215 raise SyncException(EXCEPTION_DOWNLOAD)
225
216
226 hash = get_file_hash(attached_file)
217 hash = get_file_hash(attached_file)
227 if hash != attachment.text:
218 if hash != attachment.text:
228 raise SyncException(EXCEPTION_HASH)
219 raise SyncException(EXCEPTION_HASH)
229
220
230 files.append(attached_file)
221 files.append(attached_file)
231
222
232 if is_old:
223 if is_old:
233 post = global_id.post
224 post = global_id.post
234 Post.objects.update_post(
225 Post.objects.update_post(
235 post, title=title, text=text, pub_time=pub_time,
226 post, title=title, text=text, pub_time=pub_time,
236 tags=tags, files=files, tripcode=tripcode,
227 tags=tags, files=files, tripcode=tripcode,
237 version=version)
228 version=version)
238 print('Parsed updated post {}'.format(global_id))
229 print('Parsed updated post {}'.format(global_id))
239 else:
230 else:
240 Post.objects.import_post(
231 Post.objects.import_post(
241 title=title, text=text, pub_time=pub_time,
232 title=title, text=text, pub_time=pub_time,
242 opening_post=opening_post, tags=tags,
233 opening_post=opening_post, tags=tags,
243 global_id=global_id, files=files, tripcode=tripcode,
234 global_id=global_id, files=files, tripcode=tripcode,
244 version=version)
235 version=version)
245 print('Parsed new post {}'.format(global_id))
236 print('Parsed new post {}'.format(global_id))
246 else:
237 else:
247 raise SyncException(EXCEPTION_NODE.format(tag_status.text))
238 raise SyncException(EXCEPTION_NODE.format(tag_status.text))
248
239
249 @staticmethod
240 @staticmethod
250 def generate_response_list():
241 def generate_response_list():
251 response = et.Element(TAG_RESPONSE)
242 response = et.Element(TAG_RESPONSE)
252
243
253 status = et.SubElement(response, TAG_STATUS)
244 status = et.SubElement(response, TAG_STATUS)
254 status.text = STATUS_SUCCESS
245 status.text = STATUS_SUCCESS
255
246
256 models = et.SubElement(response, TAG_MODELS)
247 models = et.SubElement(response, TAG_MODELS)
257
248
258 for post in Post.objects.prefetch_related('global_id').all():
249 for post in Post.objects.prefetch_related('global_id').all():
259 tag_model = et.SubElement(models, TAG_MODEL)
250 tag_model = et.SubElement(models, TAG_MODEL)
260 tag_id = et.SubElement(tag_model, TAG_ID)
251 tag_id = et.SubElement(tag_model, TAG_ID)
261 post.global_id.to_xml_element(tag_id)
252 post.global_id.to_xml_element(tag_id)
262 tag_version = et.SubElement(tag_model, TAG_VERSION)
253 tag_version = et.SubElement(tag_model, TAG_VERSION)
263 tag_version.text = str(post.version)
254 tag_version.text = str(post.version)
264
255
265 return et.tostring(response, ENCODING_UNICODE)
256 return et.tostring(response, ENCODING_UNICODE)
266
257
267 @staticmethod
258 @staticmethod
268 def _verify_model(global_id, content_str, tag_model):
259 def _verify_model(global_id, content_str, tag_model):
269 """
260 """
270 Verifies all signatures for a single model.
261 Verifies all signatures for a single model.
271 """
262 """
272
263
273 signatures = []
264 signatures = []
274
265
275 tag_signatures = tag_model.find(TAG_SIGNATURES)
266 tag_signatures = tag_model.find(TAG_SIGNATURES)
276 has_author_signature = False
267 has_author_signature = False
277 for tag_signature in tag_signatures:
268 for tag_signature in tag_signatures:
278 signature_type = tag_signature.get(ATTR_TYPE)
269 signature_type = tag_signature.get(ATTR_TYPE)
279 signature_value = tag_signature.get(ATTR_VALUE)
270 signature_value = tag_signature.get(ATTR_VALUE)
280 signature_key = tag_signature.get(ATTR_KEY)
271 signature_key = tag_signature.get(ATTR_KEY)
281
272
282 if global_id.key_type == signature_type and\
273 if global_id.key_type == signature_type and\
283 global_id.key == signature_key:
274 global_id.key == signature_key:
284 has_author_signature = True
275 has_author_signature = True
285
276
286 signature = Signature(key_type=signature_type,
277 signature = Signature(key_type=signature_type,
287 key=signature_key,
278 key=signature_key,
288 signature=signature_value)
279 signature=signature_value)
289
280
290 if not KeyPair.objects.verify(signature, content_str):
281 if not KeyPair.objects.verify(signature, content_str):
291 raise SyncException(EXCEPTION_SIGNATURE.format(content_str))
282 raise SyncException(EXCEPTION_SIGNATURE.format(content_str))
292
283
293 signatures.append(signature)
284 signatures.append(signature)
294 if not has_author_signature:
285 if not has_author_signature:
295 raise SyncException(EXCEPTION_AUTHOR_SIGNATURE.format(content_str))
286 raise SyncException(EXCEPTION_AUTHOR_SIGNATURE.format(content_str))
296
287
297 return signatures
288 return signatures
298
289
299 @staticmethod
290 @staticmethod
300 def _attachment_to_xml(tag_attachments, tag_refs, file, hash, url):
291 def _attachment_to_xml(tag_attachments, tag_refs, file, hash, url):
301 if tag_attachments is not None:
292 if tag_attachments is not None:
302 mimetype = get_file_mimetype(file)
293 mimetype = get_file_mimetype(file)
303 attachment = et.SubElement(tag_attachments, TAG_ATTACHMENT)
294 attachment = et.SubElement(tag_attachments, TAG_ATTACHMENT)
304 attachment.set(ATTR_MIMETYPE, mimetype)
295 attachment.set(ATTR_MIMETYPE, mimetype)
305 attachment.set(ATTR_ID_TYPE, ID_TYPE_MD5)
296 attachment.set(ATTR_ID_TYPE, ID_TYPE_MD5)
306 attachment.text = hash
297 attachment.text = hash
307
298
308 attachment_ref = et.SubElement(tag_refs, TAG_ATTACHMENT_REF)
299 attachment_ref = et.SubElement(tag_refs, TAG_ATTACHMENT_REF)
309 attachment_ref.set(ATTR_REF, hash)
300 attachment_ref.set(ATTR_REF, hash)
310 attachment_ref.set(ATTR_URL, url)
301 attachment_ref.set(ATTR_URL, url)
@@ -1,147 +1,150 b''
1 import hashlib
1 import hashlib
2 from boards.models.attachment import FILE_TYPES_IMAGE
2 from django.template.loader import render_to_string
3 from django.template.loader import render_to_string
3 from django.db import models
4 from django.db import models
4 from django.db.models import Count
5 from django.db.models import Count
5 from django.core.urlresolvers import reverse
6 from django.core.urlresolvers import reverse
6
7
7 from boards.models import PostImage
8 from boards.models import Attachment
8 from boards.models.base import Viewable
9 from boards.models.base import Viewable
9 from boards.models.thread import STATUS_ACTIVE, STATUS_BUMPLIMIT, STATUS_ARCHIVE
10 from boards.models.thread import STATUS_ACTIVE, STATUS_BUMPLIMIT, STATUS_ARCHIVE
10 from boards.utils import cached_result
11 from boards.utils import cached_result
11 import boards
12 import boards
12
13
13 __author__ = 'neko259'
14 __author__ = 'neko259'
14
15
15
16
16 RELATED_TAGS_COUNT = 5
17 RELATED_TAGS_COUNT = 5
17
18
18
19
19 class TagManager(models.Manager):
20 class TagManager(models.Manager):
20
21
21 def get_not_empty_tags(self):
22 def get_not_empty_tags(self):
22 """
23 """
23 Gets tags that have non-archived threads.
24 Gets tags that have non-archived threads.
24 """
25 """
25
26
26 return self.annotate(num_threads=Count('thread_tags')).filter(num_threads__gt=0)\
27 return self.annotate(num_threads=Count('thread_tags')).filter(num_threads__gt=0)\
27 .order_by('-required', 'name')
28 .order_by('-required', 'name')
28
29
29 def get_tag_url_list(self, tags: list) -> str:
30 def get_tag_url_list(self, tags: list) -> str:
30 """
31 """
31 Gets a comma-separated list of tag links.
32 Gets a comma-separated list of tag links.
32 """
33 """
33
34
34 return ', '.join([tag.get_view() for tag in tags])
35 return ', '.join([tag.get_view() for tag in tags])
35
36
36
37
37 class Tag(models.Model, Viewable):
38 class Tag(models.Model, Viewable):
38 """
39 """
39 A tag is a text node assigned to the thread. The tag serves as a board
40 A tag is a text node assigned to the thread. The tag serves as a board
40 section. There can be multiple tags for each thread
41 section. There can be multiple tags for each thread
41 """
42 """
42
43
43 objects = TagManager()
44 objects = TagManager()
44
45
45 class Meta:
46 class Meta:
46 app_label = 'boards'
47 app_label = 'boards'
47 ordering = ('name',)
48 ordering = ('name',)
48
49
49 name = models.CharField(max_length=100, db_index=True, unique=True)
50 name = models.CharField(max_length=100, db_index=True, unique=True)
50 required = models.BooleanField(default=False, db_index=True)
51 required = models.BooleanField(default=False, db_index=True)
51 description = models.TextField(blank=True)
52 description = models.TextField(blank=True)
52
53
53 parent = models.ForeignKey('Tag', null=True, blank=True,
54 parent = models.ForeignKey('Tag', null=True, blank=True,
54 related_name='children')
55 related_name='children')
55
56
56 def __str__(self):
57 def __str__(self):
57 return self.name
58 return self.name
58
59
59 def is_empty(self) -> bool:
60 def is_empty(self) -> bool:
60 """
61 """
61 Checks if the tag has some threads.
62 Checks if the tag has some threads.
62 """
63 """
63
64
64 return self.get_thread_count() == 0
65 return self.get_thread_count() == 0
65
66
66 def get_thread_count(self, status=None) -> int:
67 def get_thread_count(self, status=None) -> int:
67 threads = self.get_threads()
68 threads = self.get_threads()
68 if status is not None:
69 if status is not None:
69 threads = threads.filter(status=status)
70 threads = threads.filter(status=status)
70 return threads.count()
71 return threads.count()
71
72
72 def get_active_thread_count(self) -> int:
73 def get_active_thread_count(self) -> int:
73 return self.get_thread_count(status=STATUS_ACTIVE)
74 return self.get_thread_count(status=STATUS_ACTIVE)
74
75
75 def get_bumplimit_thread_count(self) -> int:
76 def get_bumplimit_thread_count(self) -> int:
76 return self.get_thread_count(status=STATUS_BUMPLIMIT)
77 return self.get_thread_count(status=STATUS_BUMPLIMIT)
77
78
78 def get_archived_thread_count(self) -> int:
79 def get_archived_thread_count(self) -> int:
79 return self.get_thread_count(status=STATUS_ARCHIVE)
80 return self.get_thread_count(status=STATUS_ARCHIVE)
80
81
81 def get_absolute_url(self):
82 def get_absolute_url(self):
82 return reverse('tag', kwargs={'tag_name': self.name})
83 return reverse('tag', kwargs={'tag_name': self.name})
83
84
84 def get_threads(self):
85 def get_threads(self):
85 return self.thread_tags.order_by('-bump_time')
86 return self.thread_tags.order_by('-bump_time')
86
87
87 def is_required(self):
88 def is_required(self):
88 return self.required
89 return self.required
89
90
90 def get_view(self):
91 def get_view(self):
91 link = '<a class="tag" href="{}">{}</a>'.format(
92 link = '<a class="tag" href="{}">{}</a>'.format(
92 self.get_absolute_url(), self.name)
93 self.get_absolute_url(), self.name)
93 if self.is_required():
94 if self.is_required():
94 link = '<b>{}</b>'.format(link)
95 link = '<b>{}</b>'.format(link)
95 return link
96 return link
96
97
97 def get_search_view(self, *args, **kwargs):
98 def get_search_view(self, *args, **kwargs):
98 return render_to_string('boards/tag.html', {
99 return render_to_string('boards/tag.html', {
99 'tag': self,
100 'tag': self,
100 })
101 })
101
102
102 @cached_result()
103 @cached_result()
103 def get_post_count(self):
104 def get_post_count(self):
104 return self.get_threads().aggregate(num_posts=Count('multi_replies'))['num_posts']
105 return self.get_threads().aggregate(num_posts=Count('multi_replies'))['num_posts']
105
106
106 def get_description(self):
107 def get_description(self):
107 return self.description
108 return self.description
108
109
109 def get_random_image_post(self, status=[STATUS_ACTIVE, STATUS_BUMPLIMIT]):
110 def get_random_image_post(self, status=[STATUS_ACTIVE, STATUS_BUMPLIMIT]):
110 posts = boards.models.Post.objects.annotate(images_count=Count(
111 posts = boards.models.Post.objects.filter(attachments__mimetype__in=FILE_TYPES_IMAGE)\
111 'images')).filter(images_count__gt=0, threads__tags__in=[self])
112 .annotate(images_count=Count(
113 'attachments')).filter(images_count__gt=0, threads__tags__in=[self])
112 if status is not None:
114 if status is not None:
113 posts = posts.filter(thread__status__in=status)
115 posts = posts.filter(thread__status__in=status)
114 return posts.order_by('?').first()
116 return posts.order_by('?').first()
115
117
116 def get_first_letter(self):
118 def get_first_letter(self):
117 return self.name and self.name[0] or ''
119 return self.name and self.name[0] or ''
118
120
119 def get_related_tags(self):
121 def get_related_tags(self):
120 return set(Tag.objects.filter(thread_tags__in=self.get_threads()).exclude(
122 return set(Tag.objects.filter(thread_tags__in=self.get_threads()).exclude(
121 id=self.id).order_by('?')[:RELATED_TAGS_COUNT])
123 id=self.id).order_by('?')[:RELATED_TAGS_COUNT])
122
124
123 @cached_result()
125 @cached_result()
124 def get_color(self):
126 def get_color(self):
125 """
127 """
126 Gets color hashed from the tag name.
128 Gets color hashed from the tag name.
127 """
129 """
128 return hashlib.md5(self.name.encode()).hexdigest()[:6]
130 return hashlib.md5(self.name.encode()).hexdigest()[:6]
129
131
130 def get_parent(self):
132 def get_parent(self):
131 return self.parent
133 return self.parent
132
134
133 def get_all_parents(self):
135 def get_all_parents(self):
134 parents = list()
136 parents = list()
135 parent = self.get_parent()
137 parent = self.get_parent()
136 if parent and parent not in parents:
138 if parent and parent not in parents:
137 parents.insert(0, parent)
139 parents.insert(0, parent)
138 parents = parent.get_all_parents() + parents
140 parents = parent.get_all_parents() + parents
139
141
140 return parents
142 return parents
141
143
142 def get_children(self):
144 def get_children(self):
143 return self.children
145 return self.children
144
146
145 def get_images(self):
147 def get_images(self):
146 return PostImage.objects.filter(post_images__thread__tags__in=[self])\
148 return Attachment.objects.filter(
147 .order_by('-post_images__pub_time') No newline at end of file
149 post_attachments__thread__tags__in=[self]).filter(
150 mimetype__in=FILE_TYPES_IMAGE).order_by('-post_images__pub_time') No newline at end of file
@@ -1,322 +1,325 b''
1 import logging
1 import logging
2 from adjacent import Client
2 from adjacent import Client
3 from boards.models.attachment import FILE_TYPES_IMAGE
3
4
4 from django.db.models import Count, Sum, QuerySet, Q
5 from django.db.models import Count, Sum, QuerySet, Q
5 from django.utils import timezone
6 from django.utils import timezone
6 from django.db import models, transaction
7 from django.db import models, transaction
7
8
8 from boards.models import STATUS_BUMPLIMIT, STATUS_ACTIVE, STATUS_ARCHIVE
9 from boards.models import STATUS_BUMPLIMIT, STATUS_ACTIVE, STATUS_ARCHIVE
9
10
10 from boards import settings
11 from boards import settings
11 import boards
12 import boards
12 from boards.utils import cached_result, datetime_to_epoch
13 from boards.utils import cached_result, datetime_to_epoch
13 from boards.models.post import Post
14 from boards.models.post import Post
14 from boards.models.tag import Tag
15 from boards.models.tag import Tag
15
16
16 FAV_THREAD_NO_UPDATES = -1
17 FAV_THREAD_NO_UPDATES = -1
17
18
18
19
19 __author__ = 'neko259'
20 __author__ = 'neko259'
20
21
21
22
22 logger = logging.getLogger(__name__)
23 logger = logging.getLogger(__name__)
23
24
24
25
25 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
26 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
26 WS_NOTIFICATION_TYPE = 'notification_type'
27 WS_NOTIFICATION_TYPE = 'notification_type'
27
28
28 WS_CHANNEL_THREAD = "thread:"
29 WS_CHANNEL_THREAD = "thread:"
29
30
30 STATUS_CHOICES = (
31 STATUS_CHOICES = (
31 (STATUS_ACTIVE, STATUS_ACTIVE),
32 (STATUS_ACTIVE, STATUS_ACTIVE),
32 (STATUS_BUMPLIMIT, STATUS_BUMPLIMIT),
33 (STATUS_BUMPLIMIT, STATUS_BUMPLIMIT),
33 (STATUS_ARCHIVE, STATUS_ARCHIVE),
34 (STATUS_ARCHIVE, STATUS_ARCHIVE),
34 )
35 )
35
36
36
37
37 class ThreadManager(models.Manager):
38 class ThreadManager(models.Manager):
38 def process_oldest_threads(self):
39 def process_oldest_threads(self):
39 """
40 """
40 Preserves maximum thread count. If there are too many threads,
41 Preserves maximum thread count. If there are too many threads,
41 archive or delete the old ones.
42 archive or delete the old ones.
42 """
43 """
43
44
44 threads = Thread.objects.exclude(status=STATUS_ARCHIVE).order_by('-bump_time')
45 threads = Thread.objects.exclude(status=STATUS_ARCHIVE).order_by('-bump_time')
45 thread_count = threads.count()
46 thread_count = threads.count()
46
47
47 max_thread_count = settings.get_int('Messages', 'MaxThreadCount')
48 max_thread_count = settings.get_int('Messages', 'MaxThreadCount')
48 if thread_count > max_thread_count:
49 if thread_count > max_thread_count:
49 num_threads_to_delete = thread_count - max_thread_count
50 num_threads_to_delete = thread_count - max_thread_count
50 old_threads = threads[thread_count - num_threads_to_delete:]
51 old_threads = threads[thread_count - num_threads_to_delete:]
51
52
52 for thread in old_threads:
53 for thread in old_threads:
53 if settings.get_bool('Storage', 'ArchiveThreads'):
54 if settings.get_bool('Storage', 'ArchiveThreads'):
54 self._archive_thread(thread)
55 self._archive_thread(thread)
55 else:
56 else:
56 thread.delete()
57 thread.delete()
57
58
58 logger.info('Processed %d old threads' % num_threads_to_delete)
59 logger.info('Processed %d old threads' % num_threads_to_delete)
59
60
60 def _archive_thread(self, thread):
61 def _archive_thread(self, thread):
61 thread.status = STATUS_ARCHIVE
62 thread.status = STATUS_ARCHIVE
62 thread.last_edit_time = timezone.now()
63 thread.last_edit_time = timezone.now()
63 thread.update_posts_time()
64 thread.update_posts_time()
64 thread.save(update_fields=['last_edit_time', 'status'])
65 thread.save(update_fields=['last_edit_time', 'status'])
65
66
66 def get_new_posts(self, datas):
67 def get_new_posts(self, datas):
67 query = None
68 query = None
68 # TODO Use classes instead of dicts
69 # TODO Use classes instead of dicts
69 for data in datas:
70 for data in datas:
70 if data['last_id'] != FAV_THREAD_NO_UPDATES:
71 if data['last_id'] != FAV_THREAD_NO_UPDATES:
71 q = (Q(id=data['op'].get_thread_id())
72 q = (Q(id=data['op'].get_thread_id())
72 & Q(multi_replies__id__gt=data['last_id']))
73 & Q(multi_replies__id__gt=data['last_id']))
73 if query is None:
74 if query is None:
74 query = q
75 query = q
75 else:
76 else:
76 query = query | q
77 query = query | q
77 if query is not None:
78 if query is not None:
78 return self.filter(query).annotate(
79 return self.filter(query).annotate(
79 new_post_count=Count('multi_replies'))
80 new_post_count=Count('multi_replies'))
80
81
81 def get_new_post_count(self, datas):
82 def get_new_post_count(self, datas):
82 new_posts = self.get_new_posts(datas)
83 new_posts = self.get_new_posts(datas)
83 return new_posts.aggregate(total_count=Count('multi_replies'))\
84 return new_posts.aggregate(total_count=Count('multi_replies'))\
84 ['total_count'] if new_posts else 0
85 ['total_count'] if new_posts else 0
85
86
86
87
87 def get_thread_max_posts():
88 def get_thread_max_posts():
88 return settings.get_int('Messages', 'MaxPostsPerThread')
89 return settings.get_int('Messages', 'MaxPostsPerThread')
89
90
90
91
91 class Thread(models.Model):
92 class Thread(models.Model):
92 objects = ThreadManager()
93 objects = ThreadManager()
93
94
94 class Meta:
95 class Meta:
95 app_label = 'boards'
96 app_label = 'boards'
96
97
97 tags = models.ManyToManyField('Tag', related_name='thread_tags')
98 tags = models.ManyToManyField('Tag', related_name='thread_tags')
98 bump_time = models.DateTimeField(db_index=True)
99 bump_time = models.DateTimeField(db_index=True)
99 last_edit_time = models.DateTimeField()
100 last_edit_time = models.DateTimeField()
100 max_posts = models.IntegerField(default=get_thread_max_posts)
101 max_posts = models.IntegerField(default=get_thread_max_posts)
101 status = models.CharField(max_length=50, default=STATUS_ACTIVE,
102 status = models.CharField(max_length=50, default=STATUS_ACTIVE,
102 choices=STATUS_CHOICES)
103 choices=STATUS_CHOICES)
103 monochrome = models.BooleanField(default=False)
104 monochrome = models.BooleanField(default=False)
104
105
105 def get_tags(self) -> QuerySet:
106 def get_tags(self) -> QuerySet:
106 """
107 """
107 Gets a sorted tag list.
108 Gets a sorted tag list.
108 """
109 """
109
110
110 return self.tags.order_by('name')
111 return self.tags.order_by('name')
111
112
112 def bump(self):
113 def bump(self):
113 """
114 """
114 Bumps (moves to up) thread if possible.
115 Bumps (moves to up) thread if possible.
115 """
116 """
116
117
117 if self.can_bump():
118 if self.can_bump():
118 self.bump_time = self.last_edit_time
119 self.bump_time = self.last_edit_time
119
120
120 self.update_bump_status()
121 self.update_bump_status()
121
122
122 logger.info('Bumped thread %d' % self.id)
123 logger.info('Bumped thread %d' % self.id)
123
124
124 def has_post_limit(self) -> bool:
125 def has_post_limit(self) -> bool:
125 return self.max_posts > 0
126 return self.max_posts > 0
126
127
127 def update_bump_status(self, exclude_posts=None):
128 def update_bump_status(self, exclude_posts=None):
128 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
129 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
129 self.status = STATUS_BUMPLIMIT
130 self.status = STATUS_BUMPLIMIT
130 self.update_posts_time(exclude_posts=exclude_posts)
131 self.update_posts_time(exclude_posts=exclude_posts)
131
132
132 def _get_cache_key(self):
133 def _get_cache_key(self):
133 return [datetime_to_epoch(self.last_edit_time)]
134 return [datetime_to_epoch(self.last_edit_time)]
134
135
135 @cached_result(key_method=_get_cache_key)
136 @cached_result(key_method=_get_cache_key)
136 def get_reply_count(self) -> int:
137 def get_reply_count(self) -> int:
137 return self.get_replies().count()
138 return self.get_replies().count()
138
139
139 @cached_result(key_method=_get_cache_key)
140 @cached_result(key_method=_get_cache_key)
140 def get_images_count(self) -> int:
141 def get_images_count(self) -> int:
141 return self.get_replies().annotate(images_count=Count(
142 return self.get_replies().filter(
142 'images')).aggregate(Sum('images_count'))['images_count__sum']
143 attachments__mimetype__in=FILE_TYPES_IMAGE)\
144 .annotate(images_count=Count(
145 'attachments')).aggregate(Sum('images_count'))['images_count__sum']
143
146
144 def can_bump(self) -> bool:
147 def can_bump(self) -> bool:
145 """
148 """
146 Checks if the thread can be bumped by replying to it.
149 Checks if the thread can be bumped by replying to it.
147 """
150 """
148
151
149 return self.get_status() == STATUS_ACTIVE
152 return self.get_status() == STATUS_ACTIVE
150
153
151 def get_last_replies(self) -> QuerySet:
154 def get_last_replies(self) -> QuerySet:
152 """
155 """
153 Gets several last replies, not including opening post
156 Gets several last replies, not including opening post
154 """
157 """
155
158
156 last_replies_count = settings.get_int('View', 'LastRepliesCount')
159 last_replies_count = settings.get_int('View', 'LastRepliesCount')
157
160
158 if last_replies_count > 0:
161 if last_replies_count > 0:
159 reply_count = self.get_reply_count()
162 reply_count = self.get_reply_count()
160
163
161 if reply_count > 0:
164 if reply_count > 0:
162 reply_count_to_show = min(last_replies_count,
165 reply_count_to_show = min(last_replies_count,
163 reply_count - 1)
166 reply_count - 1)
164 replies = self.get_replies()
167 replies = self.get_replies()
165 last_replies = replies[reply_count - reply_count_to_show:]
168 last_replies = replies[reply_count - reply_count_to_show:]
166
169
167 return last_replies
170 return last_replies
168
171
169 def get_skipped_replies_count(self) -> int:
172 def get_skipped_replies_count(self) -> int:
170 """
173 """
171 Gets number of posts between opening post and last replies.
174 Gets number of posts between opening post and last replies.
172 """
175 """
173 reply_count = self.get_reply_count()
176 reply_count = self.get_reply_count()
174 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
177 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
175 reply_count - 1)
178 reply_count - 1)
176 return reply_count - last_replies_count - 1
179 return reply_count - last_replies_count - 1
177
180
178 def get_replies(self, view_fields_only=False) -> QuerySet:
181 def get_replies(self, view_fields_only=False) -> QuerySet:
179 """
182 """
180 Gets sorted thread posts
183 Gets sorted thread posts
181 """
184 """
182
185
183 query = self.multi_replies.order_by('pub_time').prefetch_related(
186 query = self.multi_replies.order_by('pub_time').prefetch_related(
184 'images', 'thread', 'attachments')
187 'thread', 'attachments')
185 if view_fields_only:
188 if view_fields_only:
186 query = query.defer('poster_ip')
189 query = query.defer('poster_ip')
187 return query
190 return query
188
191
189 def get_top_level_replies(self) -> QuerySet:
192 def get_top_level_replies(self) -> QuerySet:
190 return self.get_replies().exclude(refposts__threads__in=[self])
193 return self.get_replies().exclude(refposts__threads__in=[self])
191
194
192 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
195 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
193 """
196 """
194 Gets replies that have at least one image attached
197 Gets replies that have at least one image attached
195 """
198 """
196
199 return self.get_replies(view_fields_only).filter(
197 return self.get_replies(view_fields_only).annotate(images_count=Count(
200 attachments__mimetype__in=FILE_TYPES_IMAGE).annotate(images_count=Count(
198 'images')).filter(images_count__gt=0)
201 'attachments')).filter(images_count__gt=0)
199
202
200 def get_opening_post(self, only_id=False) -> Post:
203 def get_opening_post(self, only_id=False) -> Post:
201 """
204 """
202 Gets the first post of the thread
205 Gets the first post of the thread
203 """
206 """
204
207
205 query = self.get_replies().filter(opening=True)
208 query = self.get_replies().filter(opening=True)
206 if only_id:
209 if only_id:
207 query = query.only('id')
210 query = query.only('id')
208 opening_post = query.first()
211 opening_post = query.first()
209
212
210 return opening_post
213 return opening_post
211
214
212 @cached_result()
215 @cached_result()
213 def get_opening_post_id(self) -> int:
216 def get_opening_post_id(self) -> int:
214 """
217 """
215 Gets ID of the first thread post.
218 Gets ID of the first thread post.
216 """
219 """
217
220
218 return self.get_opening_post(only_id=True).id
221 return self.get_opening_post(only_id=True).id
219
222
220 def get_pub_time(self):
223 def get_pub_time(self):
221 """
224 """
222 Gets opening post's pub time because thread does not have its own one.
225 Gets opening post's pub time because thread does not have its own one.
223 """
226 """
224
227
225 return self.get_opening_post().pub_time
228 return self.get_opening_post().pub_time
226
229
227 def __str__(self):
230 def __str__(self):
228 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
231 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
229
232
230 def get_tag_url_list(self) -> list:
233 def get_tag_url_list(self) -> list:
231 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
234 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
232
235
233 def update_posts_time(self, exclude_posts=None):
236 def update_posts_time(self, exclude_posts=None):
234 last_edit_time = self.last_edit_time
237 last_edit_time = self.last_edit_time
235
238
236 for post in self.multi_replies.all():
239 for post in self.multi_replies.all():
237 if exclude_posts is None or post not in exclude_posts:
240 if exclude_posts is None or post not in exclude_posts:
238 # Manual update is required because uids are generated on save
241 # Manual update is required because uids are generated on save
239 post.last_edit_time = last_edit_time
242 post.last_edit_time = last_edit_time
240 post.save(update_fields=['last_edit_time'])
243 post.save(update_fields=['last_edit_time'])
241
244
242 post.get_threads().update(last_edit_time=last_edit_time)
245 post.get_threads().update(last_edit_time=last_edit_time)
243
246
244 def notify_clients(self):
247 def notify_clients(self):
245 if not settings.get_bool('External', 'WebsocketsEnabled'):
248 if not settings.get_bool('External', 'WebsocketsEnabled'):
246 return
249 return
247
250
248 client = Client()
251 client = Client()
249
252
250 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
253 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
251 client.publish(channel_name, {
254 client.publish(channel_name, {
252 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
255 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
253 })
256 })
254 client.send()
257 client.send()
255
258
256 def get_absolute_url(self):
259 def get_absolute_url(self):
257 return self.get_opening_post().get_absolute_url()
260 return self.get_opening_post().get_absolute_url()
258
261
259 def get_required_tags(self):
262 def get_required_tags(self):
260 return self.get_tags().filter(required=True)
263 return self.get_tags().filter(required=True)
261
264
262 def get_replies_newer(self, post_id):
265 def get_replies_newer(self, post_id):
263 return self.get_replies().filter(id__gt=post_id)
266 return self.get_replies().filter(id__gt=post_id)
264
267
265 def is_archived(self):
268 def is_archived(self):
266 return self.get_status() == STATUS_ARCHIVE
269 return self.get_status() == STATUS_ARCHIVE
267
270
268 def get_status(self):
271 def get_status(self):
269 return self.status
272 return self.status
270
273
271 def is_monochrome(self):
274 def is_monochrome(self):
272 return self.monochrome
275 return self.monochrome
273
276
274 # If tags have parent, add them to the tag list
277 # If tags have parent, add them to the tag list
275 @transaction.atomic
278 @transaction.atomic
276 def refresh_tags(self):
279 def refresh_tags(self):
277 for tag in self.get_tags().all():
280 for tag in self.get_tags().all():
278 parents = tag.get_all_parents()
281 parents = tag.get_all_parents()
279 if len(parents) > 0:
282 if len(parents) > 0:
280 self.tags.add(*parents)
283 self.tags.add(*parents)
281
284
282 def get_reply_tree(self):
285 def get_reply_tree(self):
283 replies = self.get_replies().prefetch_related('refposts')
286 replies = self.get_replies().prefetch_related('refposts')
284 tree = []
287 tree = []
285 for reply in replies:
288 for reply in replies:
286 parents = reply.refposts.all()
289 parents = reply.refposts.all()
287
290
288 found_parent = False
291 found_parent = False
289 searching_for_index = False
292 searching_for_index = False
290
293
291 if len(parents) > 0:
294 if len(parents) > 0:
292 index = 0
295 index = 0
293 parent_depth = 0
296 parent_depth = 0
294
297
295 indexes_to_insert = []
298 indexes_to_insert = []
296
299
297 for depth, element in tree:
300 for depth, element in tree:
298 index += 1
301 index += 1
299
302
300 # If this element is next after parent on the same level,
303 # If this element is next after parent on the same level,
301 # insert child before it
304 # insert child before it
302 if searching_for_index and depth <= parent_depth:
305 if searching_for_index and depth <= parent_depth:
303 indexes_to_insert.append((index - 1, parent_depth))
306 indexes_to_insert.append((index - 1, parent_depth))
304 searching_for_index = False
307 searching_for_index = False
305
308
306 if element in parents:
309 if element in parents:
307 found_parent = True
310 found_parent = True
308 searching_for_index = True
311 searching_for_index = True
309 parent_depth = depth
312 parent_depth = depth
310
313
311 if not found_parent:
314 if not found_parent:
312 tree.append((0, reply))
315 tree.append((0, reply))
313 else:
316 else:
314 if searching_for_index:
317 if searching_for_index:
315 tree.append((parent_depth + 1, reply))
318 tree.append((parent_depth + 1, reply))
316
319
317 offset = 0
320 offset = 0
318 for last_index, parent_depth in indexes_to_insert:
321 for last_index, parent_depth in indexes_to_insert:
319 tree.insert(last_index + offset, (parent_depth + 1, reply))
322 tree.insert(last_index + offset, (parent_depth + 1, reply))
320 offset += 1
323 offset += 1
321
324
322 return tree
325 return tree
@@ -1,92 +1,116 b''
1 import re
1 import re
2 from boards import thumbs
2 from boards.mdx_neboard import get_parser
3 from boards.mdx_neboard import get_parser
3
4
4 from boards.models import Post, GlobalId
5 from boards.models import Post, GlobalId, Attachment
6 from boards.models.attachment.viewers import FILE_TYPES_IMAGE
5 from boards.models.post import REGEX_NOTIFICATION, REGEX_REPLY,\
7 from boards.models.post import REGEX_NOTIFICATION, REGEX_REPLY,\
6 REGEX_GLOBAL_REPLY
8 REGEX_GLOBAL_REPLY
7 from boards.models.post.manager import post_import_deps
9 from boards.models.post.manager import post_import_deps
8 from boards.models.user import Notification
10 from boards.models.user import Notification
9 from django.db.models.signals import post_save, pre_save, pre_delete, \
11 from django.db.models.signals import post_save, pre_save, pre_delete, \
10 post_delete
12 post_delete
11 from django.dispatch import receiver
13 from django.dispatch import receiver
12 from django.utils import timezone
14 from django.utils import timezone
13
15
14
16
17 THUMB_SIZES = ((200, 150),)
18
19
15 @receiver(post_save, sender=Post)
20 @receiver(post_save, sender=Post)
16 def connect_replies(instance, **kwargs):
21 def connect_replies(instance, **kwargs):
17 for reply_number in re.finditer(REGEX_REPLY, instance.get_raw_text()):
22 for reply_number in re.finditer(REGEX_REPLY, instance.get_raw_text()):
18 post_id = reply_number.group(1)
23 post_id = reply_number.group(1)
19
24
20 try:
25 try:
21 referenced_post = Post.objects.get(id=post_id)
26 referenced_post = Post.objects.get(id=post_id)
22
27
23 if not referenced_post.referenced_posts.filter(
28 if not referenced_post.referenced_posts.filter(
24 id=instance.id).exists():
29 id=instance.id).exists():
25 referenced_post.referenced_posts.add(instance)
30 referenced_post.referenced_posts.add(instance)
26 referenced_post.last_edit_time = instance.pub_time
31 referenced_post.last_edit_time = instance.pub_time
27 referenced_post.build_refmap()
32 referenced_post.build_refmap()
28 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
33 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
29 except Post.DoesNotExist:
34 except Post.DoesNotExist:
30 pass
35 pass
31
36
32
37
33 @receiver(post_save, sender=Post)
38 @receiver(post_save, sender=Post)
34 @receiver(post_import_deps, sender=Post)
39 @receiver(post_import_deps, sender=Post)
35 def connect_global_replies(instance, **kwargs):
40 def connect_global_replies(instance, **kwargs):
36 for reply_number in re.finditer(REGEX_GLOBAL_REPLY, instance.get_raw_text()):
41 for reply_number in re.finditer(REGEX_GLOBAL_REPLY, instance.get_raw_text()):
37 key_type = reply_number.group(1)
42 key_type = reply_number.group(1)
38 key = reply_number.group(2)
43 key = reply_number.group(2)
39 local_id = reply_number.group(3)
44 local_id = reply_number.group(3)
40
45
41 try:
46 try:
42 global_id = GlobalId.objects.get(key_type=key_type, key=key,
47 global_id = GlobalId.objects.get(key_type=key_type, key=key,
43 local_id=local_id)
48 local_id=local_id)
44 referenced_post = Post.objects.get(global_id=global_id)
49 referenced_post = Post.objects.get(global_id=global_id)
45 referenced_post.referenced_posts.add(instance)
50 referenced_post.referenced_posts.add(instance)
46 referenced_post.last_edit_time = instance.pub_time
51 referenced_post.last_edit_time = instance.pub_time
47 referenced_post.build_refmap()
52 referenced_post.build_refmap()
48 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
53 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
49 except (GlobalId.DoesNotExist, Post.DoesNotExist):
54 except (GlobalId.DoesNotExist, Post.DoesNotExist):
50 pass
55 pass
51
56
52
57
53 @receiver(post_save, sender=Post)
58 @receiver(post_save, sender=Post)
54 def connect_notifications(instance, **kwargs):
59 def connect_notifications(instance, **kwargs):
55 for reply_number in re.finditer(REGEX_NOTIFICATION, instance.get_raw_text()):
60 for reply_number in re.finditer(REGEX_NOTIFICATION, instance.get_raw_text()):
56 user_name = reply_number.group(1).lower()
61 user_name = reply_number.group(1).lower()
57 Notification.objects.get_or_create(name=user_name, post=instance)
62 Notification.objects.get_or_create(name=user_name, post=instance)
58
63
59
64
60 @receiver(pre_save, sender=Post)
65 @receiver(pre_save, sender=Post)
61 @receiver(post_import_deps, sender=Post)
66 @receiver(post_import_deps, sender=Post)
62 def parse_text(instance, **kwargs):
67 def parse_text(instance, **kwargs):
63 instance._text_rendered = get_parser().parse(instance.get_raw_text())
68 instance._text_rendered = get_parser().parse(instance.get_raw_text())
64
69
65
70
66 @receiver(pre_delete, sender=Post)
71 @receiver(pre_delete, sender=Post)
67 def delete_images(instance, **kwargs):
72 def delete_images(instance, **kwargs):
68 for image in instance.images.all():
73 for image in instance.images.all():
69 image_refs_count = image.post_images.count()
74 image_refs_count = image.post_images.count()
70 if image_refs_count == 1:
75 if image_refs_count == 1:
71 image.delete()
76 image.delete()
72
77
73
78
74 @receiver(pre_delete, sender=Post)
79 @receiver(pre_delete, sender=Post)
75 def delete_attachments(instance, **kwargs):
80 def delete_attachments(instance, **kwargs):
76 for attachment in instance.attachments.all():
81 for attachment in instance.attachments.all():
77 attachment_refs_count = attachment.attachment_posts.count()
82 attachment_refs_count = attachment.attachment_posts.count()
78 if attachment_refs_count == 1:
83 if attachment_refs_count == 1:
79 attachment.delete()
84 attachment.delete()
80
85
81
86
82 @receiver(post_delete, sender=Post)
87 @receiver(post_delete, sender=Post)
83 def update_thread_on_delete(instance, **kwargs):
88 def update_thread_on_delete(instance, **kwargs):
84 thread = instance.get_thread()
89 thread = instance.get_thread()
85 thread.last_edit_time = timezone.now()
90 thread.last_edit_time = timezone.now()
86 thread.save()
91 thread.save()
87
92
88
93
89 @receiver(post_delete, sender=Post)
94 @receiver(post_delete, sender=Post)
90 def delete_global_id(instance, **kwargs):
95 def delete_global_id(instance, **kwargs):
91 if instance.global_id and instance.global_id.id:
96 if instance.global_id and instance.global_id.id:
92 instance.global_id.delete()
97 instance.global_id.delete()
98
99
100 @receiver(post_save, sender=Attachment)
101 def generate_thumb(instance, **kwargs):
102 if instance.mimetype in FILE_TYPES_IMAGE:
103 for size in THUMB_SIZES:
104 (w, h) = size
105 split = instance.file.name.rsplit('.', 1)
106 thumb_name = '%s.%sx%s.%s' % (split[0], w, h, split[1])
107
108 if not instance.file.storage.exists(thumb_name):
109 # you can use another thumbnailing function if you like
110 thumb_content = thumbs.generate_thumb(instance.file, size, split[1])
111
112 thumb_name_ = instance.file.storage.save(thumb_name, thumb_content)
113
114 if not thumb_name == thumb_name_:
115 raise ValueError(
116 'There is already a file named %s' % thumb_name_)
@@ -1,210 +1,210 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.get_text|safe }}</div>
34 <div>{{ banner.get_text|safe }}</div>
35 <div>{% trans 'Details' %}: <a href="{{ banner.post.get_absolute_url }}">>>{{ banner.post.id }}</a></div>
35 <div>{% trans 'Details' %}: <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" style="border-bottom: solid .5ex #{{ tag.get_color }}">
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.get_first_image %}
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.get_thumb_url }}"
46 width="{{ image.pre_width }}"
46 width="{{ image.get_preview_size.0 }}"
47 height="{{ image.pre_height }}"
47 height="{{ image.get_preview_size.1 }}"
48 alt="{{ random_image_post.id }}"/></a>
48 alt="{{ random_image_post.id }}"/></a>
49 {% endwith %}
49 {% endwith %}
50 </div>
50 </div>
51 {% endif %}
51 {% endif %}
52 <div class="tag-text-data">
52 <div class="tag-text-data">
53 <h2>
53 <h2>
54 /{{ tag.get_view|safe }}/
54 /{{ tag.get_view|safe }}/
55 {% if perms.change_tag %}
55 {% if perms.change_tag %}
56 <span class="moderator_info">| <a href="{% url 'admin:boards_tag_change' tag.id %}">{% trans 'Edit tag' %}</a></span>
56 <span class="moderator_info">| <a href="{% url 'admin:boards_tag_change' tag.id %}">{% trans 'Edit tag' %}</a></span>
57 {% endif %}
57 {% endif %}
58 </h2>
58 </h2>
59 <p>
59 <p>
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_favorite %}
61 {% if is_favorite %}
62 <button name="method" value="unsubscribe" class="fav">★ {% trans "Remove from favorites" %}</button>
62 <button name="method" value="unsubscribe" class="fav">★ {% trans "Remove from favorites" %}</button>
63 {% else %}
63 {% else %}
64 <button name="method" value="subscribe" class="not_fav">★ {% trans "Add to favorites" %}</button>
64 <button name="method" value="subscribe" class="not_fav">★ {% trans "Add to favorites" %}</button>
65 {% endif %}
65 {% endif %}
66 </form>
66 </form>
67 <form action="{% url 'tag' tag.name %}" method="post" class="post-button-form">
67 <form action="{% url 'tag' tag.name %}" method="post" class="post-button-form">
68 {% if is_hidden %}
68 {% if is_hidden %}
69 <button name="method" value="unhide" class="fav">{% trans "Show" %}</button>
69 <button name="method" value="unhide" class="fav">{% trans "Show" %}</button>
70 {% else %}
70 {% else %}
71 <button name="method" value="hide" class="not_fav">{% trans "Hide" %}</button>
71 <button name="method" value="hide" class="not_fav">{% trans "Hide" %}</button>
72 {% endif %}
72 {% endif %}
73 </form>
73 </form>
74 <a href="{% url 'tag_gallery' tag.name %}">{% trans 'Gallery' %}</a>
74 <a href="{% url 'tag_gallery' tag.name %}">{% trans 'Gallery' %}</a>
75 </p>
75 </p>
76 {% if tag.get_description %}
76 {% if tag.get_description %}
77 <p>{{ tag.get_description|safe }}</p>
77 <p>{{ tag.get_description|safe }}</p>
78 {% endif %}
78 {% endif %}
79 <p>
79 <p>
80 {% with active_count=tag.get_active_thread_count bumplimit_count=tag.get_bumplimit_thread_count archived_count=tag.get_archived_thread_count %}
80 {% with active_count=tag.get_active_thread_count bumplimit_count=tag.get_bumplimit_thread_count archived_count=tag.get_archived_thread_count %}
81 {% if active_count %}
81 {% if active_count %}
82 {% blocktrans count count=active_count %}{{ count }} active thread{% plural %}active threads{% endblocktrans %},
82 {% blocktrans count count=active_count %}{{ count }} active thread{% plural %}active threads{% endblocktrans %},
83 {% endif %}
83 {% endif %}
84 {% if bumplimit_count %}
84 {% if bumplimit_count %}
85 {% blocktrans count count=bumplimit_count %}{{ count }} thread in bumplimit{% plural %} threads in bumplimit{% endblocktrans %},
85 {% blocktrans count count=bumplimit_count %}{{ count }} thread in bumplimit{% plural %} threads in bumplimit{% endblocktrans %},
86 {% endif %}
86 {% endif %}
87 {% if archived_count %}
87 {% if archived_count %}
88 {% blocktrans count count=archived_count %}{{ count }} archived thread{% plural %}archived threads{% endblocktrans %},
88 {% blocktrans count count=archived_count %}{{ count }} archived thread{% plural %}archived threads{% endblocktrans %},
89 {% endif %}
89 {% endif %}
90 {% endwith %}
90 {% endwith %}
91 {% blocktrans count count=tag.get_post_count %}{{ count }} message{% plural %}messages{% endblocktrans %}.
91 {% blocktrans count count=tag.get_post_count %}{{ count }} message{% plural %}messages{% endblocktrans %}.
92 </p>
92 </p>
93 {% if tag.get_all_parents %}
93 {% if tag.get_all_parents %}
94 <p>
94 <p>
95 {% for parent in tag.get_all_parents %}
95 {% for parent in tag.get_all_parents %}
96 {{ parent.get_view|safe }} &gt;
96 {{ parent.get_view|safe }} &gt;
97 {% endfor %}
97 {% endfor %}
98 {{ tag.get_view|safe }}
98 {{ tag.get_view|safe }}
99 </p>
99 </p>
100 {% endif %}
100 {% endif %}
101 {% if tag.get_children.all %}
101 {% if tag.get_children.all %}
102 <p>
102 <p>
103 {% trans "Subsections: " %}
103 {% trans "Subsections: " %}
104 {% for child in tag.get_children.all %}
104 {% for child in tag.get_children.all %}
105 {{ child.get_view|safe }}{% if not forloop.last%}, {% endif %}
105 {{ child.get_view|safe }}{% if not forloop.last%}, {% endif %}
106 {% endfor %}
106 {% endfor %}
107 </p>
107 </p>
108 {% endif %}
108 {% endif %}
109 </div>
109 </div>
110 </div>
110 </div>
111 {% endif %}
111 {% endif %}
112
112
113 {% if threads %}
113 {% if threads %}
114 {% if prev_page_link %}
114 {% if prev_page_link %}
115 <div class="page_link">
115 <div class="page_link">
116 <a href="{{ prev_page_link }}">{% trans "Previous page" %}</a>
116 <a href="{{ prev_page_link }}">{% trans "Previous page" %}</a>
117 </div>
117 </div>
118 {% endif %}
118 {% endif %}
119
119
120 {% for thread in threads %}
120 {% for thread in threads %}
121 <div class="thread">
121 <div class="thread">
122 {% post_view thread.get_opening_post thread=thread truncated=True need_open_link=True %}
122 {% post_view thread.get_opening_post thread=thread truncated=True need_open_link=True %}
123 {% if not thread.archived %}
123 {% if not thread.archived %}
124 {% with last_replies=thread.get_last_replies %}
124 {% with last_replies=thread.get_last_replies %}
125 {% if last_replies %}
125 {% if last_replies %}
126 {% with skipped_replies_count=thread.get_skipped_replies_count %}
126 {% with skipped_replies_count=thread.get_skipped_replies_count %}
127 {% if skipped_replies_count %}
127 {% if skipped_replies_count %}
128 <div class="skipped_replies">
128 <div class="skipped_replies">
129 <a href="{% url 'thread' thread.get_opening_post_id %}">
129 <a href="{% url 'thread' thread.get_opening_post_id %}">
130 {% 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 %}
130 {% 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 %}
131 </a>
131 </a>
132 </div>
132 </div>
133 {% endif %}
133 {% endif %}
134 {% endwith %}
134 {% endwith %}
135 <div class="last-replies">
135 <div class="last-replies">
136 {% for post in last_replies %}
136 {% for post in last_replies %}
137 {% post_view post truncated=True %}
137 {% post_view post truncated=True %}
138 {% endfor %}
138 {% endfor %}
139 </div>
139 </div>
140 {% endif %}
140 {% endif %}
141 {% endwith %}
141 {% endwith %}
142 {% endif %}
142 {% endif %}
143 </div>
143 </div>
144 {% endfor %}
144 {% endfor %}
145
145
146 {% if next_page_link %}
146 {% if next_page_link %}
147 <div class="page_link">
147 <div class="page_link">
148 <a href="{{ next_page_link }}">{% trans "Next page" %}</a>
148 <a href="{{ next_page_link }}">{% trans "Next page" %}</a>
149 </div>
149 </div>
150 {% endif %}
150 {% endif %}
151 {% else %}
151 {% else %}
152 <div class="post">
152 <div class="post">
153 {% trans 'No threads exist. Create the first one!' %}</div>
153 {% trans 'No threads exist. Create the first one!' %}</div>
154 {% endif %}
154 {% endif %}
155
155
156 <div class="post-form-w">
156 <div class="post-form-w">
157 <script src="{% static 'js/panel.js' %}"></script>
157 <script src="{% static 'js/panel.js' %}"></script>
158 <div class="post-form" data-hasher="{% static 'js/3party/sha256.js' %}"
158 <div class="post-form" data-hasher="{% static 'js/3party/sha256.js' %}"
159 data-pow-script="{% static 'js/proof_of_work.js' %}">
159 data-pow-script="{% static 'js/proof_of_work.js' %}">
160 <div class="form-title">{% trans "Create new thread" %}</div>
160 <div class="form-title">{% trans "Create new thread" %}</div>
161 <div class="swappable-form-full">
161 <div class="swappable-form-full">
162 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
162 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
163 {{ form.as_div }}
163 {{ form.as_div }}
164 <div class="form-submit">
164 <div class="form-submit">
165 <input type="submit" value="{% trans "Post" %}"/>
165 <input type="submit" value="{% trans "Post" %}"/>
166 <button id="preview-button" onclick="return false;">{% trans 'Preview' %}</button>
166 <button id="preview-button" onclick="return false;">{% trans 'Preview' %}</button>
167 <button id="file-source-button" onclick="return false;">{% trans 'Change file source' %}</button>
167 <button id="file-source-button" onclick="return false;">{% trans 'Change file source' %}</button>
168 </div>
168 </div>
169 </form>
169 </form>
170 </div>
170 </div>
171 <div>
171 <div>
172 {% trans 'Tags must be delimited by spaces. Text or image is required.' %}
172 {% trans 'Tags must be delimited by spaces. Text or image is required.' %}
173 {% with size=max_file_size|filesizeformat %}
173 {% with size=max_file_size|filesizeformat %}
174 {% blocktrans %}Max file size is {{ size }}.{% endblocktrans %}
174 {% blocktrans %}Max file size is {{ size }}.{% endblocktrans %}
175 {% endwith %}
175 {% endwith %}
176 </div>
176 </div>
177 <div id="preview-text"></div>
177 <div id="preview-text"></div>
178 <div><a href="{% url "staticpage" name="help" %}">{% trans 'Text syntax' %}</a></div>
178 <div><a href="{% url "staticpage" name="help" %}">{% trans 'Text syntax' %}</a></div>
179 <div><a href="{% url "tags" "required" %}">{% trans 'Tags' %}</a></div>
179 <div><a href="{% url "tags" "required" %}">{% trans 'Tags' %}</a></div>
180 </div>
180 </div>
181 </div>
181 </div>
182
182
183 <script src="{% static 'js/form.js' %}"></script>
183 <script src="{% static 'js/form.js' %}"></script>
184 <script src="{% static 'js/3party/jquery.blockUI.js' %}"></script>
184 <script src="{% static 'js/3party/jquery.blockUI.js' %}"></script>
185 <script src="{% static 'js/thread_create.js' %}"></script>
185 <script src="{% static 'js/thread_create.js' %}"></script>
186
186
187 {% endblock %}
187 {% endblock %}
188
188
189 {% block metapanel %}
189 {% block metapanel %}
190
190
191 <span class="metapanel">
191 <span class="metapanel">
192 <b><a href="{% url "authors" %}">{{ site_name }}</a> {{ version }}</b>
192 <b><a href="{% url "authors" %}">{{ site_name }}</a> {{ version }}</b>
193 {% trans "Pages:" %}
193 {% trans "Pages:" %}
194 [
194 [
195 {% with dividers=paginator.get_dividers %}
195 {% with dividers=paginator.get_dividers %}
196 {% for page in paginator.get_divided_range %}
196 {% for page in paginator.get_divided_range %}
197 {% if page in dividers %}
197 {% if page in dividers %}
198 …,
198 …,
199 {% endif %}
199 {% endif %}
200 <a
200 <a
201 {% ifequal page current_page.number %}
201 {% ifequal page current_page.number %}
202 class="current_page"
202 class="current_page"
203 {% endifequal %}
203 {% endifequal %}
204 href="{% page_url paginator page %}">{{ page }}</a>{% if not forloop.last %},{% endif %}
204 href="{% page_url paginator page %}">{{ page }}</a>{% if not forloop.last %},{% endif %}
205 {% endfor %}
205 {% endfor %}
206 {% endwith %}
206 {% endwith %}
207 ]
207 ]
208 </span>
208 </span>
209
209
210 {% endblock %}
210 {% endblock %}
@@ -1,44 +1,44 b''
1 {% extends "boards/base.html" %}
1 {% extends "boards/base.html" %}
2
2
3 {% load i18n %}
3 {% load i18n %}
4 {% load tz %}
4 {% load tz %}
5
5
6 {% block head %}
6 {% block head %}
7 <meta name="robots" content="noindex">
7 <meta name="robots" content="noindex">
8 <title>{% trans 'Settings' %} - {{ site_name }}</title>
8 <title>{% trans 'Settings' %} - {{ site_name }}</title>
9 {% endblock %}
9 {% endblock %}
10
10
11 {% block content %}
11 {% block content %}
12 <div class="post">
12 <div class="post">
13 <p>
13 <p>
14 {% if moderator %}
14 {% if moderator %}
15 {% trans 'You are moderator.' %}
15 {% trans 'You are moderator.' %}
16 {% endif %}
16 {% endif %}
17 </p>
17 </p>
18 {% if hidden_tags %}
18 {% if hidden_tags %}
19 <p>{% trans 'Hidden tags:' %}
19 <p>{% trans 'Hidden tags:' %}
20 {% for tag in hidden_tags %}
20 {% for tag in hidden_tags %}
21 {{ tag.get_view|safe }}
21 {{ tag.get_view|safe }}
22 {% endfor %}
22 {% endfor %}
23 </p>
23 </p>
24 {% else %}
24 {% else %}
25 <p>{% trans 'No hidden tags.' %}</p>
25 <p>{% trans 'No hidden tags.' %}</p>
26 {% endif %}
26 {% endif %}
27
27
28 {% for image in image_aliases %}
28 {% for image in image_aliases %}
29 {{ image.alias }}: <img src="{{ image.image.url_200x150 }}" /> <br />
29 <div>{{ image.alias }}: {{ image.get_view|safe }}</div>
30 {% endfor %}
30 {% endfor %}
31 </div>
31 </div>
32
32
33 <div class="post-form-w">
33 <div class="post-form-w">
34 <div class="post-form">
34 <div class="post-form">
35 <form method="post">{% csrf_token %}
35 <form method="post">{% csrf_token %}
36 {{ form.as_div }}
36 {{ form.as_div }}
37 <div class="form-submit">
37 <div class="form-submit">
38 <input type="submit" value="{% trans "Save" %}" />
38 <input type="submit" value="{% trans "Save" %}" />
39 </div>
39 </div>
40 </form>
40 </form>
41 </div>
41 </div>
42 </div>
42 </div>
43
43
44 {% endblock %}
44 {% endblock %}
@@ -1,39 +1,39 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 head %}
8 {% block head %}
9 <meta name="robots" content="noindex">
9 <meta name="robots" content="noindex">
10 <title>{{ thread.get_opening_post.get_title|striptags|truncatewords:10 }}
10 <title>{{ thread.get_opening_post.get_title|striptags|truncatewords:10 }}
11 - {{ site_name }}</title>
11 - {{ site_name }}</title>
12 {% endblock %}
12 {% endblock %}
13
13
14 {% block thread_content %}
14 {% block thread_content %}
15 {% get_current_language as LANGUAGE_CODE %}
15 {% get_current_language as LANGUAGE_CODE %}
16 {% get_current_timezone as TIME_ZONE %}
16 {% get_current_timezone as TIME_ZONE %}
17
17
18 <div id="posts-table">
18 <div id="posts-table">
19 {% if posts %}
19 {% if posts %}
20 {% for post in posts %}
20 {% for post in posts %}
21 <div class="gallery_image">
21 <div class="gallery_image">
22 {% with post.get_first_image as image %}
22 {% with post.get_first_image as image %}
23 {% autoescape off %}
23 {% autoescape off %}
24 {{ image.get_view }}
24 {{ image.get_view }}
25 <div class="gallery_image_metadata">
25 <div class="gallery_image_metadata">
26 {{ image.width }}x{{ image.height }}
26 {{ image.get_size.0 }}x{{ image.get_size.1 }}
27 {% image_actions image.image.url request.get_host %}
27 {% image_actions image.image.url request.get_host %}
28 <br />
28 <br />
29 <a href="{{ post.get_absolute_url }}">>>{{ post.id }}</a>
29 <a href="{{ post.get_absolute_url }}">>>{{ post.id }}</a>
30 </div>
30 </div>
31 {% endautoescape %}
31 {% endautoescape %}
32 {% endwith %}
32 {% endwith %}
33 </div>
33 </div>
34 {% endfor %}
34 {% endfor %}
35 {% else %}
35 {% else %}
36 {% trans 'No images.' %}
36 {% trans 'No images.' %}
37 {% endif %}
37 {% endif %}
38 </div>
38 </div>
39 {% endblock %}
39 {% endblock %}
@@ -1,149 +1,144 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 from boards.abstracts.constants import FILE_DIRECTORY
5 from random import random
6 from random import random
6 import time
7 import time
7 import hmac
8 import hmac
8
9
9 from django.core.cache import cache
10 from django.core.cache import cache
10 from django.db.models import Model
11 from django.db.models import Model
11 from django import forms
12 from django import forms
12 from django.template.defaultfilters import filesizeformat
13 from django.template.defaultfilters import filesizeformat
13 from django.utils import timezone
14 from django.utils import timezone
14 from django.utils.translation import ugettext_lazy as _
15 from django.utils.translation import ugettext_lazy as _
15 import magic
16 import magic
16 import os
17 import os
17
18
18 import boards
19 import boards
19 from boards.settings import get_bool
20 from boards.settings import get_bool
20 from neboard import settings
21 from neboard import settings
21
22
22 CACHE_KEY_DELIMITER = '_'
23 CACHE_KEY_DELIMITER = '_'
23
24
24 HTTP_FORWARDED = 'HTTP_X_FORWARDED_FOR'
25 HTTP_FORWARDED = 'HTTP_X_FORWARDED_FOR'
25 META_REMOTE_ADDR = 'REMOTE_ADDR'
26 META_REMOTE_ADDR = 'REMOTE_ADDR'
26
27
27 SETTING_MESSAGES = 'Messages'
28 SETTING_MESSAGES = 'Messages'
28 SETTING_ANON_MODE = 'AnonymousMode'
29 SETTING_ANON_MODE = 'AnonymousMode'
29
30
30 ANON_IP = '127.0.0.1'
31 ANON_IP = '127.0.0.1'
31
32
32 UPLOAD_DIRS ={
33 'PostImage': 'images/',
34 'Attachment': 'files/',
35 }
36 FILE_EXTENSION_DELIMITER = '.'
33 FILE_EXTENSION_DELIMITER = '.'
37
34
38
35
39 def is_anonymous_mode():
36 def is_anonymous_mode():
40 return get_bool(SETTING_MESSAGES, SETTING_ANON_MODE)
37 return get_bool(SETTING_MESSAGES, SETTING_ANON_MODE)
41
38
42
39
43 def get_client_ip(request):
40 def get_client_ip(request):
44 if is_anonymous_mode():
41 if is_anonymous_mode():
45 ip = ANON_IP
42 ip = ANON_IP
46 else:
43 else:
47 x_forwarded_for = request.META.get(HTTP_FORWARDED)
44 x_forwarded_for = request.META.get(HTTP_FORWARDED)
48 if x_forwarded_for:
45 if x_forwarded_for:
49 ip = x_forwarded_for.split(',')[-1].strip()
46 ip = x_forwarded_for.split(',')[-1].strip()
50 else:
47 else:
51 ip = request.META.get(META_REMOTE_ADDR)
48 ip = request.META.get(META_REMOTE_ADDR)
52 return ip
49 return ip
53
50
54
51
55 # TODO The output format is not epoch because it includes microseconds
52 # TODO The output format is not epoch because it includes microseconds
56 def datetime_to_epoch(datetime):
53 def datetime_to_epoch(datetime):
57 return int(time.mktime(timezone.localtime(
54 return int(time.mktime(timezone.localtime(
58 datetime,timezone.get_current_timezone()).timetuple())
55 datetime,timezone.get_current_timezone()).timetuple())
59 * 1000000 + datetime.microsecond)
56 * 1000000 + datetime.microsecond)
60
57
61
58
62 def get_websocket_token(user_id='', timestamp=''):
59 def get_websocket_token(user_id='', timestamp=''):
63 """
60 """
64 Create token to validate information provided by new connection.
61 Create token to validate information provided by new connection.
65 """
62 """
66
63
67 sign = hmac.new(settings.CENTRIFUGE_PROJECT_SECRET.encode())
64 sign = hmac.new(settings.CENTRIFUGE_PROJECT_SECRET.encode())
68 sign.update(settings.CENTRIFUGE_PROJECT_ID.encode())
65 sign.update(settings.CENTRIFUGE_PROJECT_ID.encode())
69 sign.update(user_id.encode())
66 sign.update(user_id.encode())
70 sign.update(timestamp.encode())
67 sign.update(timestamp.encode())
71 token = sign.hexdigest()
68 token = sign.hexdigest()
72
69
73 return token
70 return token
74
71
75
72
76 # TODO Test this carefully
73 # TODO Test this carefully
77 def cached_result(key_method=None):
74 def cached_result(key_method=None):
78 """
75 """
79 Caches method result in the Django's cache system, persisted by object name,
76 Caches method result in the Django's cache system, persisted by object name,
80 object name, model id if object is a Django model, args and kwargs if any.
77 object name, model id if object is a Django model, args and kwargs if any.
81 """
78 """
82 def _cached_result(function):
79 def _cached_result(function):
83 def inner_func(obj, *args, **kwargs):
80 def inner_func(obj, *args, **kwargs):
84 cache_key_params = [obj.__class__.__name__, function.__name__]
81 cache_key_params = [obj.__class__.__name__, function.__name__]
85
82
86 cache_key_params += args
83 cache_key_params += args
87 for key, value in kwargs:
84 for key, value in kwargs:
88 cache_key_params.append(key + ':' + value)
85 cache_key_params.append(key + ':' + value)
89
86
90 if isinstance(obj, Model):
87 if isinstance(obj, Model):
91 cache_key_params.append(str(obj.id))
88 cache_key_params.append(str(obj.id))
92
89
93 if key_method is not None:
90 if key_method is not None:
94 cache_key_params += [str(arg) for arg in key_method(obj)]
91 cache_key_params += [str(arg) for arg in key_method(obj)]
95
92
96 cache_key = CACHE_KEY_DELIMITER.join(cache_key_params)
93 cache_key = CACHE_KEY_DELIMITER.join(cache_key_params)
97
94
98 persisted_result = cache.get(cache_key)
95 persisted_result = cache.get(cache_key)
99 if persisted_result is not None:
96 if persisted_result is not None:
100 result = persisted_result
97 result = persisted_result
101 else:
98 else:
102 result = function(obj, *args, **kwargs)
99 result = function(obj, *args, **kwargs)
103 if result is not None:
100 if result is not None:
104 cache.set(cache_key, result)
101 cache.set(cache_key, result)
105
102
106 return result
103 return result
107
104
108 return inner_func
105 return inner_func
109 return _cached_result
106 return _cached_result
110
107
111
108
112 def get_file_hash(file) -> str:
109 def get_file_hash(file) -> str:
113 md5 = hashlib.md5()
110 md5 = hashlib.md5()
114 for chunk in file.chunks():
111 for chunk in file.chunks():
115 md5.update(chunk)
112 md5.update(chunk)
116 return md5.hexdigest()
113 return md5.hexdigest()
117
114
118
115
119 def validate_file_size(size: int):
116 def validate_file_size(size: int):
120 max_size = boards.settings.get_int('Forms', 'MaxFileSize')
117 max_size = boards.settings.get_int('Forms', 'MaxFileSize')
121 if size > max_size:
118 if size > max_size:
122 raise forms.ValidationError(
119 raise forms.ValidationError(
123 _('File must be less than %s but is %s.')
120 _('File must be less than %s but is %s.')
124 % (filesizeformat(max_size), filesizeformat(size)))
121 % (filesizeformat(max_size), filesizeformat(size)))
125
122
126
123
127 def get_extension(filename):
124 def get_extension(filename):
128 return filename.split(FILE_EXTENSION_DELIMITER)[-1:][0]
125 return filename.split(FILE_EXTENSION_DELIMITER)[-1:][0]
129
126
130
127
131 def get_upload_filename(model_instance, old_filename):
128 def get_upload_filename(model_instance, old_filename):
132 # TODO Use something other than random number in file name
129 # TODO Use something other than random number in file name
133 extension = get_extension(old_filename)
130 extension = get_extension(old_filename)
134 new_name = '{}{}.{}'.format(
131 new_name = '{}{}.{}'.format(
135 str(int(time.mktime(time.gmtime()))),
132 str(int(time.mktime(time.gmtime()))),
136 str(int(random() * 1000)),
133 str(int(random() * 1000)),
137 extension)
134 extension)
138
135
139 directory = UPLOAD_DIRS[type(model_instance).__name__]
136 return os.path.join(FILE_DIRECTORY, new_name)
140
141 return os.path.join(directory, new_name)
142
137
143
138
144 def get_file_mimetype(file) -> str:
139 def get_file_mimetype(file) -> str:
145 type = magic.from_buffer(file.chunks().__next__(), mime=True)
140 type = magic.from_buffer(file.chunks().__next__(), mime=True)
146 if type is not None:
141 if type is not None:
147 return type.decode()
142 return type.decode()
148 else:
143 else:
149 return 'application/octet-stream'
144 return 'application/octet-stream'
@@ -1,169 +1,168 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
9 from django.utils.decorators import method_decorator
8 from django.utils.decorators import method_decorator
10 from django.views.decorators.csrf import csrf_protect
9 from django.views.decorators.csrf import csrf_protect
11
10
12 from boards import utils, settings
11 from boards import utils, settings
13 from boards.abstracts.paginator import get_paginator
12 from boards.abstracts.paginator import get_paginator
14 from boards.abstracts.settingsmanager import get_settings_manager
13 from boards.abstracts.settingsmanager import get_settings_manager
15 from boards.forms import ThreadForm, PlainErrorList
14 from boards.forms import ThreadForm, PlainErrorList
16 from boards.models import Post, Thread, Ban, Tag, PostImage, Banner
15 from boards.models import Post, Thread, Ban, Banner
17 from boards.views.banned import BannedView
16 from boards.views.banned import BannedView
18 from boards.views.base import BaseBoardView, CONTEXT_FORM
17 from boards.views.base import BaseBoardView, CONTEXT_FORM
19 from boards.views.posting_mixin import PostMixin
18 from boards.views.posting_mixin import PostMixin
20 from boards.views.mixins import FileUploadMixin, PaginatedMixin
19 from boards.views.mixins import FileUploadMixin, PaginatedMixin
21
20
22 FORM_TAGS = 'tags'
21 FORM_TAGS = 'tags'
23 FORM_TEXT = 'text'
22 FORM_TEXT = 'text'
24 FORM_TITLE = 'title'
23 FORM_TITLE = 'title'
25 FORM_IMAGE = 'image'
24 FORM_IMAGE = 'image'
26 FORM_THREADS = 'threads'
25 FORM_THREADS = 'threads'
27
26
28 TAG_DELIMITER = ' '
27 TAG_DELIMITER = ' '
29
28
30 PARAMETER_CURRENT_PAGE = 'current_page'
29 PARAMETER_CURRENT_PAGE = 'current_page'
31 PARAMETER_PAGINATOR = 'paginator'
30 PARAMETER_PAGINATOR = 'paginator'
32 PARAMETER_THREADS = 'threads'
31 PARAMETER_THREADS = 'threads'
33 PARAMETER_BANNERS = 'banners'
32 PARAMETER_BANNERS = 'banners'
34 PARAMETER_ADDITIONAL = 'additional_params'
33 PARAMETER_ADDITIONAL = 'additional_params'
35 PARAMETER_MAX_FILE_SIZE = 'max_file_size'
34 PARAMETER_MAX_FILE_SIZE = 'max_file_size'
36 PARAMETER_RSS_URL = 'rss_url'
35 PARAMETER_RSS_URL = 'rss_url'
37
36
38 TEMPLATE = 'boards/all_threads.html'
37 TEMPLATE = 'boards/all_threads.html'
39 DEFAULT_PAGE = 1
38 DEFAULT_PAGE = 1
40
39
41
40
42 class AllThreadsView(PostMixin, FileUploadMixin, BaseBoardView, PaginatedMixin):
41 class AllThreadsView(PostMixin, FileUploadMixin, BaseBoardView, PaginatedMixin):
43
42
44 def __init__(self):
43 def __init__(self):
45 self.settings_manager = None
44 self.settings_manager = None
46 super(AllThreadsView, self).__init__()
45 super(AllThreadsView, self).__init__()
47
46
48 @method_decorator(csrf_protect)
47 @method_decorator(csrf_protect)
49 def get(self, request, form: ThreadForm=None):
48 def get(self, request, form: ThreadForm=None):
50 page = request.GET.get('page', DEFAULT_PAGE)
49 page = request.GET.get('page', DEFAULT_PAGE)
51
50
52 params = self.get_context_data(request=request)
51 params = self.get_context_data(request=request)
53
52
54 if not form:
53 if not form:
55 form = ThreadForm(error_class=PlainErrorList)
54 form = ThreadForm(error_class=PlainErrorList)
56
55
57 self.settings_manager = get_settings_manager(request)
56 self.settings_manager = get_settings_manager(request)
58
57
59 threads = self.get_threads()
58 threads = self.get_threads()
60
59
61 order = request.GET.get('order', 'bump')
60 order = request.GET.get('order', 'bump')
62 if order == 'bump':
61 if order == 'bump':
63 threads = threads.order_by('-bump_time')
62 threads = threads.order_by('-bump_time')
64 else:
63 else:
65 threads = threads.filter(multi_replies__opening=True).order_by('-multi_replies__pub_time')
64 threads = threads.filter(multi_replies__opening=True).order_by('-multi_replies__pub_time')
66 filter = request.GET.get('filter')
65 filter = request.GET.get('filter')
67 if filter == 'fav_tags':
66 if filter == 'fav_tags':
68 fav_tags = self.settings_manager.get_fav_tags()
67 fav_tags = self.settings_manager.get_fav_tags()
69 if len(fav_tags) > 0:
68 if len(fav_tags) > 0:
70 threads = threads.filter(tags__in=fav_tags)
69 threads = threads.filter(tags__in=fav_tags)
71 threads = threads.distinct()
70 threads = threads.distinct()
72
71
73 paginator = get_paginator(threads,
72 paginator = get_paginator(threads,
74 settings.get_int('View', 'ThreadsPerPage'))
73 settings.get_int('View', 'ThreadsPerPage'))
75 paginator.current_page = int(page)
74 paginator.current_page = int(page)
76
75
77 try:
76 try:
78 threads = paginator.page(page).object_list
77 threads = paginator.page(page).object_list
79 except EmptyPage:
78 except EmptyPage:
80 raise Http404()
79 raise Http404()
81
80
82 params[PARAMETER_THREADS] = threads
81 params[PARAMETER_THREADS] = threads
83 params[CONTEXT_FORM] = form
82 params[CONTEXT_FORM] = form
84 params[PARAMETER_BANNERS] = Banner.objects.order_by('-id').all()
83 params[PARAMETER_BANNERS] = Banner.objects.order_by('-id').all()
85 params[PARAMETER_MAX_FILE_SIZE] = self.get_max_upload_size()
84 params[PARAMETER_MAX_FILE_SIZE] = self.get_max_upload_size()
86 params[PARAMETER_RSS_URL] = self.get_rss_url()
85 params[PARAMETER_RSS_URL] = self.get_rss_url()
87
86
88 paginator.set_url(self.get_reverse_url(), request.GET.dict())
87 paginator.set_url(self.get_reverse_url(), request.GET.dict())
89 self.get_page_context(paginator, params, page)
88 self.get_page_context(paginator, params, page)
90
89
91 return render(request, TEMPLATE, params)
90 return render(request, TEMPLATE, params)
92
91
93 @method_decorator(csrf_protect)
92 @method_decorator(csrf_protect)
94 def post(self, request):
93 def post(self, request):
95 form = ThreadForm(request.POST, request.FILES,
94 form = ThreadForm(request.POST, request.FILES,
96 error_class=PlainErrorList)
95 error_class=PlainErrorList)
97 form.session = request.session
96 form.session = request.session
98
97
99 if form.is_valid():
98 if form.is_valid():
100 return self.create_thread(request, form)
99 return self.create_thread(request, form)
101 if form.need_to_ban:
100 if form.need_to_ban:
102 # Ban user because he is suspected to be a bot
101 # Ban user because he is suspected to be a bot
103 self._ban_current_user(request)
102 self._ban_current_user(request)
104
103
105 return self.get(request, form)
104 return self.get(request, form)
106
105
107 def get_page_context(self, paginator, params, page):
106 def get_page_context(self, paginator, params, page):
108 """
107 """
109 Get pagination context variables
108 Get pagination context variables
110 """
109 """
111
110
112 params[PARAMETER_PAGINATOR] = paginator
111 params[PARAMETER_PAGINATOR] = paginator
113 current_page = paginator.page(int(page))
112 current_page = paginator.page(int(page))
114 params[PARAMETER_CURRENT_PAGE] = current_page
113 params[PARAMETER_CURRENT_PAGE] = current_page
115 self.set_page_urls(paginator, params)
114 self.set_page_urls(paginator, params)
116
115
117 def get_reverse_url(self):
116 def get_reverse_url(self):
118 return reverse('index')
117 return reverse('index')
119
118
120 @transaction.atomic
119 @transaction.atomic
121 def create_thread(self, request, form: ThreadForm, html_response=True):
120 def create_thread(self, request, form: ThreadForm, html_response=True):
122 """
121 """
123 Creates a new thread with an opening post.
122 Creates a new thread with an opening post.
124 """
123 """
125
124
126 ip = utils.get_client_ip(request)
125 ip = utils.get_client_ip(request)
127 is_banned = Ban.objects.filter(ip=ip).exists()
126 is_banned = Ban.objects.filter(ip=ip).exists()
128
127
129 if is_banned:
128 if is_banned:
130 if html_response:
129 if html_response:
131 return redirect(BannedView().as_view())
130 return redirect(BannedView().as_view())
132 else:
131 else:
133 return
132 return
134
133
135 data = form.cleaned_data
134 data = form.cleaned_data
136
135
137 title = form.get_title()
136 title = form.get_title()
138 text = data[FORM_TEXT]
137 text = data[FORM_TEXT]
139 file = form.get_file()
138 file = form.get_file()
140 threads = data[FORM_THREADS]
139 threads = data[FORM_THREADS]
141 images = form.get_images()
140 images = form.get_images()
142
141
143 text = self._remove_invalid_links(text)
142 text = self._remove_invalid_links(text)
144
143
145 tags = data[FORM_TAGS]
144 tags = data[FORM_TAGS]
146 monochrome = form.is_monochrome()
145 monochrome = form.is_monochrome()
147
146
148 post = Post.objects.create_post(title=title, text=text, file=file,
147 post = Post.objects.create_post(title=title, text=text, file=file,
149 ip=ip, tags=tags, opening_posts=threads,
148 ip=ip, tags=tags, opening_posts=threads,
150 tripcode=form.get_tripcode(),
149 tripcode=form.get_tripcode(),
151 monochrome=monochrome, images=images)
150 monochrome=monochrome, images=images)
152
151
153 # This is required to update the threads to which posts we have replied
152 # This is required to update the threads to which posts we have replied
154 # when creating this one
153 # when creating this one
155 post.notify_clients()
154 post.notify_clients()
156
155
157 if html_response:
156 if html_response:
158 return redirect(post.get_absolute_url())
157 return redirect(post.get_absolute_url())
159
158
160 def get_threads(self):
159 def get_threads(self):
161 """
160 """
162 Gets list of threads that will be shown on a page.
161 Gets list of threads that will be shown on a page.
163 """
162 """
164
163
165 return Thread.objects\
164 return Thread.objects\
166 .exclude(tags__in=self.settings_manager.get_hidden_tags())
165 .exclude(tags__in=self.settings_manager.get_hidden_tags())
167
166
168 def get_rss_url(self):
167 def get_rss_url(self):
169 return self.get_reverse_url() + 'rss/'
168 return self.get_reverse_url() + 'rss/'
@@ -1,22 +1,22 b''
1 from django.shortcuts import render
1 from django.shortcuts import render
2 from django.views.generic import View
2 from django.views.generic import View
3
3
4 from boards.models import PostImage
4 from boards.models import Attachment
5
5
6 __author__ = 'neko259'
6 __author__ = 'neko259'
7
7
8 TEMPLATE = 'boards/random.html'
8 TEMPLATE = 'boards/random.html'
9
9
10 CONTEXT_IMAGES = 'images'
10 CONTEXT_IMAGES = 'images'
11
11
12 RANDOM_POST_COUNT = 9
12 RANDOM_POST_COUNT = 9
13
13
14
14
15 class RandomImageView(View):
15 class RandomImageView(View):
16 def get(self, request):
16 def get(self, request):
17 params = dict()
17 params = dict()
18
18
19 params[CONTEXT_IMAGES] = PostImage.objects.get_random_images(
19 params[CONTEXT_IMAGES] = Attachment.objects.get_random_images(
20 RANDOM_POST_COUNT)
20 RANDOM_POST_COUNT)
21
21
22 return render(request, TEMPLATE, params)
22 return render(request, TEMPLATE, params)
@@ -1,79 +1,79 b''
1 from django.db import transaction
1 from django.db import transaction
2 from django.shortcuts import render, redirect
2 from django.shortcuts import render, redirect
3 from django.utils import timezone
3 from django.utils import timezone
4
4
5 from boards.abstracts.settingsmanager import get_settings_manager, \
5 from boards.abstracts.settingsmanager import get_settings_manager, \
6 SETTING_USERNAME, SETTING_LAST_NOTIFICATION_ID, SETTING_IMAGE_VIEWER
6 SETTING_USERNAME, SETTING_LAST_NOTIFICATION_ID, SETTING_IMAGE_VIEWER
7 from boards.middlewares import SESSION_TIMEZONE
7 from boards.middlewares import SESSION_TIMEZONE
8 from boards.views.base import BaseBoardView, CONTEXT_FORM
8 from boards.views.base import BaseBoardView, CONTEXT_FORM
9 from boards.forms import SettingsForm, PlainErrorList
9 from boards.forms import SettingsForm, PlainErrorList
10 from boards import settings
10 from boards import settings
11 from boards.models import PostImage
11 from boards.models import Attachment
12
12
13 FORM_THEME = 'theme'
13 FORM_THEME = 'theme'
14 FORM_USERNAME = 'username'
14 FORM_USERNAME = 'username'
15 FORM_TIMEZONE = 'timezone'
15 FORM_TIMEZONE = 'timezone'
16 FORM_IMAGE_VIEWER = 'image_viewer'
16 FORM_IMAGE_VIEWER = 'image_viewer'
17
17
18 CONTEXT_HIDDEN_TAGS = 'hidden_tags'
18 CONTEXT_HIDDEN_TAGS = 'hidden_tags'
19 CONTEXT_IMAGE_ALIASES = 'image_aliases'
19 CONTEXT_IMAGE_ALIASES = 'image_aliases'
20
20
21 TEMPLATE = 'boards/settings.html'
21 TEMPLATE = 'boards/settings.html'
22
22
23
23
24 class SettingsView(BaseBoardView):
24 class SettingsView(BaseBoardView):
25
25
26 def get(self, request):
26 def get(self, request):
27 params = dict()
27 params = dict()
28 settings_manager = get_settings_manager(request)
28 settings_manager = get_settings_manager(request)
29
29
30 selected_theme = settings_manager.get_theme()
30 selected_theme = settings_manager.get_theme()
31
31
32 form = SettingsForm(
32 form = SettingsForm(
33 initial={
33 initial={
34 FORM_THEME: selected_theme,
34 FORM_THEME: selected_theme,
35 FORM_IMAGE_VIEWER: settings_manager.get_setting(
35 FORM_IMAGE_VIEWER: settings_manager.get_setting(
36 SETTING_IMAGE_VIEWER,
36 SETTING_IMAGE_VIEWER,
37 default=settings.get('View', 'DefaultImageViewer')),
37 default=settings.get('View', 'DefaultImageViewer')),
38 FORM_USERNAME: settings_manager.get_setting(SETTING_USERNAME),
38 FORM_USERNAME: settings_manager.get_setting(SETTING_USERNAME),
39 FORM_TIMEZONE: request.session.get(
39 FORM_TIMEZONE: request.session.get(
40 SESSION_TIMEZONE, timezone.get_current_timezone()),
40 SESSION_TIMEZONE, timezone.get_current_timezone()),
41 },
41 },
42 error_class=PlainErrorList)
42 error_class=PlainErrorList)
43
43
44 params[CONTEXT_FORM] = form
44 params[CONTEXT_FORM] = form
45 params[CONTEXT_HIDDEN_TAGS] = settings_manager.get_hidden_tags()
45 params[CONTEXT_HIDDEN_TAGS] = settings_manager.get_hidden_tags()
46 params[CONTEXT_IMAGE_ALIASES] = PostImage.objects.exclude(alias='').exclude(alias=None)
46 params[CONTEXT_IMAGE_ALIASES] = Attachment.objects.exclude(alias='').exclude(alias=None)
47
47
48 return render(request, TEMPLATE, params)
48 return render(request, TEMPLATE, params)
49
49
50 def post(self, request):
50 def post(self, request):
51 settings_manager = get_settings_manager(request)
51 settings_manager = get_settings_manager(request)
52
52
53 with transaction.atomic():
53 with transaction.atomic():
54 form = SettingsForm(request.POST, error_class=PlainErrorList)
54 form = SettingsForm(request.POST, error_class=PlainErrorList)
55
55
56 if form.is_valid():
56 if form.is_valid():
57 selected_theme = form.cleaned_data[FORM_THEME]
57 selected_theme = form.cleaned_data[FORM_THEME]
58 username = form.cleaned_data[FORM_USERNAME].lower()
58 username = form.cleaned_data[FORM_USERNAME].lower()
59
59
60 settings_manager.set_theme(selected_theme)
60 settings_manager.set_theme(selected_theme)
61 settings_manager.set_setting(SETTING_IMAGE_VIEWER,
61 settings_manager.set_setting(SETTING_IMAGE_VIEWER,
62 form.cleaned_data[FORM_IMAGE_VIEWER])
62 form.cleaned_data[FORM_IMAGE_VIEWER])
63
63
64 old_username = settings_manager.get_setting(SETTING_USERNAME)
64 old_username = settings_manager.get_setting(SETTING_USERNAME)
65 if username != old_username:
65 if username != old_username:
66 settings_manager.set_setting(SETTING_USERNAME, username)
66 settings_manager.set_setting(SETTING_USERNAME, username)
67 settings_manager.set_setting(SETTING_LAST_NOTIFICATION_ID, None)
67 settings_manager.set_setting(SETTING_LAST_NOTIFICATION_ID, None)
68
68
69 request.session[SESSION_TIMEZONE] = form.cleaned_data[FORM_TIMEZONE]
69 request.session[SESSION_TIMEZONE] = form.cleaned_data[FORM_TIMEZONE]
70
70
71 return redirect('settings')
71 return redirect('settings')
72 else:
72 else:
73 params = dict()
73 params = dict()
74
74
75 params[CONTEXT_FORM] = form
75 params[CONTEXT_FORM] = form
76 params[CONTEXT_HIDDEN_TAGS] = settings_manager.get_hidden_tags()
76 params[CONTEXT_HIDDEN_TAGS] = settings_manager.get_hidden_tags()
77
77
78 return render(request, TEMPLATE, params)
78 return render(request, TEMPLATE, params)
79
79
@@ -1,118 +1,118 b''
1 from django.shortcuts import get_object_or_404, redirect
1 from django.shortcuts import get_object_or_404, redirect
2 from django.core.urlresolvers import reverse
2 from django.core.urlresolvers import reverse
3
3
4 from boards.abstracts.settingsmanager import get_settings_manager, \
4 from boards.abstracts.settingsmanager import get_settings_manager, \
5 SETTING_FAVORITE_TAGS, SETTING_HIDDEN_TAGS
5 SETTING_FAVORITE_TAGS, SETTING_HIDDEN_TAGS
6 from boards.models import Tag, PostImage
6 from boards.models import Tag
7 from boards.views.all_threads import AllThreadsView, DEFAULT_PAGE
7 from boards.views.all_threads import AllThreadsView
8 from boards.views.mixins import DispatcherMixin, PARAMETER_METHOD
8 from boards.views.mixins import DispatcherMixin, PARAMETER_METHOD
9 from boards.forms import ThreadForm, PlainErrorList
9 from boards.forms import ThreadForm, PlainErrorList
10
10
11 PARAM_HIDDEN_TAGS = 'hidden_tags'
11 PARAM_HIDDEN_TAGS = 'hidden_tags'
12 PARAM_TAG = 'tag'
12 PARAM_TAG = 'tag'
13 PARAM_IS_FAVORITE = 'is_favorite'
13 PARAM_IS_FAVORITE = 'is_favorite'
14 PARAM_IS_HIDDEN = 'is_hidden'
14 PARAM_IS_HIDDEN = 'is_hidden'
15 PARAM_RANDOM_IMAGE_POST = 'random_image_post'
15 PARAM_RANDOM_IMAGE_POST = 'random_image_post'
16 PARAM_RELATED_TAGS = 'related_tags'
16 PARAM_RELATED_TAGS = 'related_tags'
17
17
18
18
19 __author__ = 'neko259'
19 __author__ = 'neko259'
20
20
21
21
22 class TagView(AllThreadsView, DispatcherMixin):
22 class TagView(AllThreadsView, DispatcherMixin):
23
23
24 tag_name = None
24 tag_name = None
25
25
26 def get_threads(self):
26 def get_threads(self):
27 tag = get_object_or_404(Tag, name=self.tag_name)
27 tag = get_object_or_404(Tag, name=self.tag_name)
28
28
29 hidden_tags = self.settings_manager.get_hidden_tags()
29 hidden_tags = self.settings_manager.get_hidden_tags()
30
30
31 try:
31 try:
32 hidden_tags.remove(tag)
32 hidden_tags.remove(tag)
33 except ValueError:
33 except ValueError:
34 pass
34 pass
35
35
36 return tag.get_threads().exclude(
36 return tag.get_threads().exclude(
37 tags__in=hidden_tags)
37 tags__in=hidden_tags)
38
38
39 def get_context_data(self, **kwargs):
39 def get_context_data(self, **kwargs):
40 params = super(TagView, self).get_context_data(**kwargs)
40 params = super(TagView, self).get_context_data(**kwargs)
41
41
42 settings_manager = get_settings_manager(kwargs['request'])
42 settings_manager = get_settings_manager(kwargs['request'])
43
43
44 tag = get_object_or_404(Tag, name=self.tag_name)
44 tag = get_object_or_404(Tag, name=self.tag_name)
45 params[PARAM_TAG] = tag
45 params[PARAM_TAG] = tag
46
46
47 fav_tag_names = settings_manager.get_setting(SETTING_FAVORITE_TAGS)
47 fav_tag_names = settings_manager.get_setting(SETTING_FAVORITE_TAGS)
48 hidden_tag_names = settings_manager.get_setting(SETTING_HIDDEN_TAGS)
48 hidden_tag_names = settings_manager.get_setting(SETTING_HIDDEN_TAGS)
49
49
50 params[PARAM_IS_FAVORITE] = fav_tag_names is not None and tag.name in fav_tag_names
50 params[PARAM_IS_FAVORITE] = fav_tag_names is not None and tag.name in fav_tag_names
51 params[PARAM_IS_HIDDEN] = hidden_tag_names is not None and tag.name in hidden_tag_names
51 params[PARAM_IS_HIDDEN] = hidden_tag_names is not None and tag.name in hidden_tag_names
52
52
53 params[PARAM_RANDOM_IMAGE_POST] = tag.get_random_image_post()
53 params[PARAM_RANDOM_IMAGE_POST] = tag.get_random_image_post()
54 params[PARAM_RELATED_TAGS] = tag.get_related_tags()
54 params[PARAM_RELATED_TAGS] = tag.get_related_tags()
55
55
56 return params
56 return params
57
57
58 def get_reverse_url(self):
58 def get_reverse_url(self):
59 return reverse('tag', kwargs={'tag_name': self.tag_name})
59 return reverse('tag', kwargs={'tag_name': self.tag_name})
60
60
61 def get(self, request, tag_name, form=None):
61 def get(self, request, tag_name, form=None):
62 self.tag_name = tag_name
62 self.tag_name = tag_name
63
63
64 return super(TagView, self).get(request, form)
64 return super(TagView, self).get(request, form)
65
65
66
66
67 def post(self, request, tag_name):
67 def post(self, request, tag_name):
68 self.tag_name = tag_name
68 self.tag_name = tag_name
69
69
70 if PARAMETER_METHOD in request.POST:
70 if PARAMETER_METHOD in request.POST:
71 self.dispatch_method(request)
71 self.dispatch_method(request)
72
72
73 return redirect('tag', tag_name)
73 return redirect('tag', tag_name)
74 else:
74 else:
75 form = ThreadForm(request.POST, request.FILES,
75 form = ThreadForm(request.POST, request.FILES,
76 error_class=PlainErrorList)
76 error_class=PlainErrorList)
77 form.session = request.session
77 form.session = request.session
78
78
79 if form.is_valid():
79 if form.is_valid():
80 return self.create_thread(request, form)
80 return self.create_thread(request, form)
81 if form.need_to_ban:
81 if form.need_to_ban:
82 # Ban user because he is suspected to be a bot
82 # Ban user because he is suspected to be a bot
83 self._ban_current_user(request)
83 self._ban_current_user(request)
84
84
85 return self.get(request, tag_name, form)
85 return self.get(request, tag_name, form)
86
86
87 def subscribe(self, request):
87 def subscribe(self, request):
88 tag = get_object_or_404(Tag, name=self.tag_name)
88 tag = get_object_or_404(Tag, name=self.tag_name)
89
89
90 settings_manager = get_settings_manager(request)
90 settings_manager = get_settings_manager(request)
91 settings_manager.add_fav_tag(tag)
91 settings_manager.add_fav_tag(tag)
92
92
93 def unsubscribe(self, request):
93 def unsubscribe(self, request):
94 tag = get_object_or_404(Tag, name=self.tag_name)
94 tag = get_object_or_404(Tag, name=self.tag_name)
95
95
96 settings_manager = get_settings_manager(request)
96 settings_manager = get_settings_manager(request)
97 settings_manager.del_fav_tag(tag)
97 settings_manager.del_fav_tag(tag)
98
98
99 def hide(self, request):
99 def hide(self, request):
100 """
100 """
101 Adds tag to user's hidden tags. Threads with this tag will not be
101 Adds tag to user's hidden tags. Threads with this tag will not be
102 shown.
102 shown.
103 """
103 """
104
104
105 tag = get_object_or_404(Tag, name=self.tag_name)
105 tag = get_object_or_404(Tag, name=self.tag_name)
106
106
107 settings_manager = get_settings_manager(request)
107 settings_manager = get_settings_manager(request)
108 settings_manager.add_hidden_tag(tag)
108 settings_manager.add_hidden_tag(tag)
109
109
110 def unhide(self, request):
110 def unhide(self, request):
111 """
111 """
112 Removed tag from user's hidden tags.
112 Removed tag from user's hidden tags.
113 """
113 """
114
114
115 tag = get_object_or_404(Tag, name=self.tag_name)
115 tag = get_object_or_404(Tag, name=self.tag_name)
116
116
117 settings_manager = get_settings_manager(request)
117 settings_manager = get_settings_manager(request)
118 settings_manager.del_hidden_tag(tag)
118 settings_manager.del_hidden_tag(tag)
1 NO CONTENT: file was removed
NO CONTENT: file was removed
General Comments 0
You need to be logged in to leave comments. Login now