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