##// END OF EJS Templates
Fixed unicode tags validation. Moved constants from classes to modules. Added error 404 when trying to get the non-existent thread. This fixes #19
neko259 -
r71:e89d805e default
parent child Browse files
Show More
@@ -1,63 +1,63 b''
1 1 import re
2 2 from django import forms
3 3 from neboard import settings
4 4
5 5
6 6 class PostForm(forms.Form):
7 7 MAX_TEXT_LENGTH = 10000
8 8 MAX_IMAGE_SIZE = 8 * 1024 * 1024
9 9
10 10 title = forms.CharField(max_length=50, required=False)
11 11 text = forms.CharField(widget=forms.Textarea, required=False)
12 12 image = forms.ImageField(required=False)
13 13
14 14 def clean_text(self):
15 15 text = self.cleaned_data['text']
16 16 if text:
17 17 if len(text) > self.MAX_TEXT_LENGTH:
18 18 raise forms.ValidationError('Too many text')
19 19 return text
20 20
21 21 def clean_image(self):
22 22 image = self.cleaned_data['image']
23 23 if image:
24 24 if image._size > self.MAX_IMAGE_SIZE:
25 25 raise forms.ValidationError('Too large image: more than ' +
26 26 str(self.MAX_IMAGE_SIZE) + ' bytes')
27 27 return image
28 28
29 29 def clean(self):
30 30 cleaned_data = super(PostForm, self).clean()
31 31
32 32 text = cleaned_data.get('text')
33 33 image = cleaned_data.get('image')
34 34
35 35 if (not text) and (not image):
36 36 raise forms.ValidationError('Enter either text or image')
37 37
38 38 return cleaned_data
39 39
40 40
41 41 class ThreadForm(PostForm):
42 regex_tags = re.compile(r'^[\w\s]+$')
42 regex_tags = re.compile(ur'^[\w\s\d]+$', re.UNICODE)
43 43
44 44 tags = forms.CharField(max_length=100)
45 45
46 46 def clean_tags(self):
47 47 tags = self.cleaned_data['tags']
48 48
49 49 if tags:
50 50 if not self.regex_tags.match(tags):
51 51 raise forms.ValidationError(
52 52 'Inappropriate characters in tags')
53 53
54 54 return tags
55 55
56 56 def clean(self):
57 57 cleaned_data = super(ThreadForm, self).clean()
58 58
59 59 return cleaned_data
60 60
61 61
62 62 class SettingsForm(forms.Form):
63 63 theme = forms.ChoiceField(choices=settings.THEMES, widget=forms.RadioSelect) No newline at end of file
@@ -1,279 +1,278 b''
1 1 import os
2 2 from random import random
3 3 import re
4 from django.db import models
5 from django.utils import timezone
6 4 import time
7 5 import math
8 import markdown
9 from markdown.inlinepatterns import Pattern
10 from markdown.util import etree
6
7 from django.db import models
8 from django.http import Http404
9 from django.utils import timezone
10 from markupfield.fields import MarkupField
11 11
12 12 from neboard import settings
13 from markupfield.fields import MarkupField
13 import thumbs
14 14
15 import thumbs
16 15
17 16 NO_PARENT = -1
18 17 NO_IP = '0.0.0.0'
19 18 UNKNOWN_UA = ''
19 ALL_PAGES = -1
20 OPENING_POST_WEIGHT = 5
21 IMAGES_DIRECTORY = 'images/'
22 FILE_EXTENSION_DELIMITER = '.'
23
24 REGEX_PRETTY = re.compile(r'^\d(0)+$')
25 REGEX_SAME = re.compile(r'^(.)\1+$')
20 26
21 27
22 28 class PostManager(models.Manager):
23 ALL_PAGES = -1
24
25 29 def create_post(self, title, text, image=None, parent_id=NO_PARENT,
26 30 ip=NO_IP, tags=None):
27 31 post = self.create(title=title,
28 32 text=text,
29 33 pub_time=timezone.now(),
30 34 parent=parent_id,
31 35 image=image,
32 36 poster_ip=ip,
33 37 poster_user_agent=UNKNOWN_UA,
34 38 last_edit_time=timezone.now())
35 39
36 40 if tags:
37 41 for tag in tags:
38 42 post.tags.add(tag)
39 43
40 44 if parent_id != NO_PARENT:
41 45 self._bump_thread(parent_id)
42 46 else:
43 47 self._delete_old_threads()
44 48
45 49 return post
46 50
47 51 def delete_post(self, post):
48 52 children = self.filter(parent=post.id)
49 53 for child in children:
50 54 self.delete_post(child)
51 55 post.delete()
52 56
53 57 def delete_posts_by_ip(self, ip):
54 58 posts = self.filter(poster_ip=ip)
55 59 for post in posts:
56 60 self.delete_post(post)
57 61
58 62 def get_threads(self, tag=None, page=ALL_PAGES):
59 63 if tag:
60 64 threads = self.filter(parent=NO_PARENT, tags=tag)
61 65 else:
62 66 threads = self.filter(parent=NO_PARENT)
63 67 threads = threads.order_by('-last_edit_time')
64 68
65 if page != self.ALL_PAGES:
69 if page != ALL_PAGES:
66 70 thread_count = len(threads)
67 71
68 72 if page < self.get_thread_page_count(tag=tag):
69 73 start_thread = page * settings.THREADS_PER_PAGE
70 74 end_thread = min(start_thread + settings.THREADS_PER_PAGE,
71 75 thread_count)
72 76 threads = threads[start_thread:end_thread]
73 77
74 78 return threads
75 79
76 80 def get_thread(self, opening_post_id):
77 81 opening_post = self.get(id=opening_post_id)
78 82
83 if not opening_post:
84 raise Http404
85
79 86 if opening_post.parent == NO_PARENT:
80 87 replies = self.filter(parent=opening_post_id)
81 88
82 89 thread = [opening_post]
83 90 thread.extend(replies)
84 91
85 92 return thread
86 93
87 94 def exists(self, post_id):
88 95 posts = self.filter(id=post_id)
89 96
90 97 return posts.count() > 0
91 98
92 99 def get_thread_page_count(self, tag=None):
93 100 if tag:
94 101 threads = self.filter(parent=NO_PARENT, tags=tag)
95 102 else:
96 103 threads = self.filter(parent=NO_PARENT)
97 104
98 105 return int(math.ceil(threads.count() / float(
99 106 settings.THREADS_PER_PAGE)))
100 107
101 108 def _delete_old_threads(self):
102 109 """
103 110 Preserves maximum thread count. If there are too many threads,
104 111 delete the old ones.
105 112 """
106 113
107 114 # TODO Move old threads to the archive instead of deleting them.
108 115 # Maybe make some 'old' field in the model to indicate the thread
109 116 # must not be shown and be able for replying.
110 117
111 118 threads = self.get_threads()
112 119 thread_count = len(threads)
113 120
114 121 if thread_count > settings.MAX_THREAD_COUNT:
115 122 num_threads_to_delete = thread_count - settings.MAX_THREAD_COUNT
116 123 old_threads = threads[thread_count - num_threads_to_delete:]
117 124
118 125 for thread in old_threads:
119 126 self.delete_post(thread)
120 127
121 128 def _bump_thread(self, thread_id):
122 129 thread = self.get(id=thread_id)
123 130
124 131 if thread.can_bump():
125 132 thread.last_edit_time = timezone.now()
126 133 thread.save()
127 134
128 135
129 136 class TagManager(models.Manager):
130 137 def get_not_empty_tags(self):
131 138 all_tags = self.all().order_by('name')
132 139 tags = []
133 140 for tag in all_tags:
134 141 if not tag.is_empty():
135 142 tags.append(tag)
136 143
137 144 return tags
138 145
139 146 def get_popular_tags(self):
140 147 all_tags = self.get_not_empty_tags()
141 148
142 149 sorted_tags = sorted(all_tags, key=lambda tag: tag.get_popularity(),
143 150 reverse=True)
144 151
145 152 return sorted_tags[:settings.POPULAR_TAGS]
146 153
147 154
148 155 class Tag(models.Model):
149 156 """
150 157 A tag is a text node assigned to the post. The tag serves as a board
151 158 section. There can be multiple tags for each message
152 159 """
153 160
154 OPENING_POST_WEIGHT = 5
155
156 161 objects = TagManager()
157 162
158 163 name = models.CharField(max_length=100)
159 164 # TODO Connect the tag to its posts to check the number of threads for
160 165 # the tag.
161 166
162 167 def __unicode__(self):
163 168 return self.name
164 169
165 170 def is_empty(self):
166 171 return self.get_post_count() == 0
167 172
168 173 def get_post_count(self):
169 174 posts_with_tag = Post.objects.get_threads(tag=self)
170 175 return posts_with_tag.count()
171 176
172 177 def get_popularity(self):
173 178 posts_with_tag = Post.objects.get_threads(tag=self)
174 179 reply_count = 0
175 180 for post in posts_with_tag:
176 181 reply_count += post.get_reply_count()
177 182 reply_count += self.OPENING_POST_WEIGHT
178 183
179 184 return reply_count
180 185
181 186
182 187 class Post(models.Model):
183 188 """A post is a message."""
184 189
185 IMAGES_DIRECTORY = 'images/'
186 FILE_EXTENSION_DELIMITER = '.'
187
188 190 objects = PostManager()
189 191
190 192 def _update_image_filename(self, filename):
191 193 """Get unique image filename"""
192 194
193 path = self.IMAGES_DIRECTORY
195 path = IMAGES_DIRECTORY
194 196 new_name = str(int(time.mktime(time.gmtime())))
195 197 new_name += str(int(random() * 1000))
196 new_name += self.FILE_EXTENSION_DELIMITER
197 new_name += filename.split(self.FILE_EXTENSION_DELIMITER)[-1:][0]
198 new_name += FILE_EXTENSION_DELIMITER
199 new_name += filename.split(FILE_EXTENSION_DELIMITER)[-1:][0]
198 200
199 201 return os.path.join(path, new_name)
200 202
201 203 title = models.CharField(max_length=50)
202 204 pub_time = models.DateTimeField()
203 205 text = MarkupField(default_markup_type='markdown', escape_html=True)
204 206 image = thumbs.ImageWithThumbsField(upload_to=_update_image_filename,
205 207 blank=True, sizes=((200, 150),))
206 208 poster_ip = models.IPAddressField()
207 209 poster_user_agent = models.TextField()
208 210 parent = models.BigIntegerField()
209 211 tags = models.ManyToManyField(Tag)
210 212 last_edit_time = models.DateTimeField()
211 213
212 regex_pretty = re.compile(r'^\d(0)+$')
213 regex_same = re.compile(r'^(.)\1+$')
214
215 214 def __unicode__(self):
216 215 return self.title + ' (' + self.text.raw + ')'
217 216
218 217 def _get_replies(self):
219 218 return Post.objects.filter(parent=self.id)
220 219
221 220 def get_reply_count(self):
222 221 return self._get_replies().count()
223 222
224 223 def get_images_count(self):
225 224 images_count = 1 if self.image else 0
226 225 for reply in self._get_replies():
227 226 if reply.image:
228 227 images_count += 1
229 228
230 229 return images_count
231 230
232 231 def get_gets_count(self):
233 232 gets_count = 1 if self.is_get() else 0
234 233 for reply in self._get_replies():
235 234 if reply.is_get():
236 235 gets_count += 1
237 236
238 237 return gets_count
239 238
240 239 def is_get(self):
241 240 """If the post has pretty id (1, 1000, 77777), than it is called GET"""
242 241
243 242 first = self.id == 1
244 243
245 244 id_str = str(self.id)
246 pretty = self.regex_pretty.match(id_str)
247 same_digits = self.regex_same.match(id_str)
245 pretty = REGEX_PRETTY.match(id_str)
246 same_digits = REGEX_SAME.match(id_str)
248 247
249 248 return first or pretty or same_digits
250 249
251 250 def can_bump(self):
252 251 """Check if the thread can be bumped by replying"""
253 252
254 253 replies_count = len(Post.objects.get_thread(self.id))
255 254
256 255 return replies_count <= settings.MAX_POSTS_PER_THREAD
257 256
258 257 def get_last_replies(self):
259 258 if settings.LAST_REPLIES_COUNT > 0:
260 259 reply_count = self.get_reply_count()
261 260
262 261 if reply_count > 0:
263 262 reply_count_to_show = min(settings.LAST_REPLIES_COUNT,
264 263 reply_count)
265 264 last_replies = self._get_replies()[reply_count
266 265 - reply_count_to_show:]
267 266
268 267 return last_replies
269 268
270 269
271 270 class Admin(models.Model):
272 271 """
273 272 Model for admin users
274 273 """
275 274 name = models.CharField(max_length=100)
276 275 password = models.CharField(max_length=100)
277 276
278 277 def __unicode__(self):
279 278 return self.name + '/' + '*' * len(self.password) No newline at end of file
@@ -1,133 +1,162 b''
1 # coding=utf-8
1 2 from django.utils.unittest import TestCase
2 3 from django.test.client import Client
3 4
4 5 import boards
5 6
6 7 from boards.models import Post, Admin, Tag
7 8 from neboard import settings
8 9
10 TEST_TEXT = 'test text'
11
12 NEW_THREAD_PAGE = '/'
13 HTTP_CODE_REDIRECT = 302
14
9 15
10 16 class BoardTests(TestCase):
11 17 def _create_post(self):
12 18 return Post.objects.create_post(title='title',
13 19 text='text')
14 20
15 21 def test_post_add(self):
16 22 post = self._create_post()
17 23
18 24 self.assertIsNotNone(post)
19 25 self.assertEqual(boards.models.NO_PARENT, post.parent)
20 26
21 27 def test_delete_post(self):
22 28 post = self._create_post()
23 29 post_id = post.id
24 30
25 31 Post.objects.delete_post(post)
26 32
27 33 self.assertFalse(Post.objects.exists(post_id))
28 34
29 35 def test_delete_posts_by_ip(self):
30 36 post = self._create_post()
31 37 post_id = post.id
32 38
33 39 Post.objects.delete_posts_by_ip('0.0.0.0')
34 40
35 41 self.assertFalse(Post.objects.exists(post_id))
36 42
37 43 # Authentication tests
38 44
39 45 def _create_test_user(self):
40 46 admin = Admin(name='test_username12313584353165',
41 47 password='test_userpassword135135512')
42 48
43 49 admin.save()
44 50 return admin
45 51
46 52 def test_admin_login(self):
47 53 client = Client()
48 54
49 55 self.assertFalse('admin' in client.session)
50 56
51 57 admin = self._create_test_user()
52 58
53 59 response = client.post('/login',
54 60 {'name': admin.name, 'password': admin.password})
55 61
56 62 # it means that login passed and user are redirected to another page
57 63 self.assertEqual(302, response.status_code)
58 64
59 65 self.assertTrue('admin' in client.session)
60 66 self.assertTrue(client.session['admin'])
61 67
62 68 admin.delete()
63 69
64 70 wrong_name = 'sd2f1s3d21fs3d21f'
65 71 wrong_password = 'sd2f1s3d21fs3d21fsdfsd'
66 72
67 73 client.post('/login', {'name': wrong_name, 'password': wrong_password})
68 74 self.assertFalse(client.session['admin'])
69 75
70 76 def test_admin_logout(self):
71 77 client = Client()
72 78
73 79 self.assertFalse('admin' in client.session)
74 80
75 81 admin = self._create_test_user()
76 82
77 83 client.post('/login',
78 84 {'name': admin.name, 'password': admin.password})
79 85
80 86 self.assertTrue(client.session['admin'])
81 87
82 88 client.get('/logout')
83 89
84 90 self.assertFalse(client.session['admin'])
85 91
86 92 admin.delete()
87 93
88 94 def test_get_thread(self):
89 95 opening_post = self._create_post()
90 96 op_id = opening_post.id
91 97
92 98 for i in range(0, 2):
93 99 Post.objects.create_post('title', 'text',
94 100 parent_id=op_id)
95 101
96 102 thread = Post.objects.get_thread(op_id)
97 103
98 104 self.assertEqual(3, len(thread))
99 105
100 106 def test_create_post_with_tag(self):
101 107 tag = Tag.objects.create(name='test_tag')
102 108 post = Post.objects.create_post(title='title', text='text', tags=[tag])
103 109 self.assertIsNotNone(post)
104 110
105 111 def test_thread_max_count(self):
106 112 for i in range(settings.MAX_THREAD_COUNT + 1):
107 113 self._create_post()
108 114
109 115 self.assertEqual(settings.MAX_THREAD_COUNT,
110 116 len(Post.objects.get_threads()))
111 117
112 118 def test_get(self):
113 119 """Test if the get computes properly"""
114 120
115 121 post = self._create_post()
116 122
117 123 self.assertTrue(post.is_get())
118 124
119 125 def test_pages(self):
120 126 """Test that the thread list is properly split into pages"""
121 127
122 PAGE_NUMBER = 2
123
124 128 for i in range(settings.MAX_THREAD_COUNT):
125 129 self._create_post()
126 130
127 131 all_threads = Post.objects.get_threads()
128 132
129 133 posts_in_second_page = Post.objects.get_threads(page=1)
130 134 first_post = posts_in_second_page[0]
131 135
132 136 self.assertEqual(all_threads[settings.THREADS_PER_PAGE].id,
133 first_post.id) No newline at end of file
137 first_post.id)
138
139 def test_post_validation(self):
140 """Test the validation of the post form"""
141
142 Post.objects.all().delete()
143
144 client = Client()
145
146 valid_tags = u'tag1 tag_2 Ρ‚Π΅Π³_3'
147 invalid_tags = u'$%_356 ---'
148
149 response = client.post(NEW_THREAD_PAGE, {'title': 'test title',
150 'text': TEST_TEXT,
151 'tags': valid_tags})
152 self.assertEqual(response.status_code, HTTP_CODE_REDIRECT,
153 msg='Posting new message failed: got code ' +
154 str(response.status_code))
155
156 self.assertEqual(1, Post.objects.count(),
157 msg='No posts were created')
158
159 response = client.post(NEW_THREAD_PAGE, {'text': TEST_TEXT,
160 'tags': invalid_tags})
161 self.assertEqual(1, Post.objects.count(), msg='The validation passed '
162 'where it should fail') No newline at end of file
@@ -1,193 +1,192 b''
1 1 from django.core.urlresolvers import reverse
2 2 from django.template import RequestContext
3 3 from boards import forms
4 4 import boards
5 5 from boards.forms import ThreadForm, PostForm, SettingsForm
6 6 from boards.models import Post, Admin, Tag
7 7 from django.shortcuts import render, get_list_or_404, redirect
8 8 from django.http import HttpResponseRedirect, Http404
9 9 import neboard
10 10
11 11
12 12 def index(request, page=0):
13 13 context = RequestContext(request)
14 14
15 15 if request.method == 'POST':
16 16 return new_post(request)
17 17 else:
18 18 threads = Post.objects.get_threads(page=int(page))
19 19
20 20 # TODO Get rid of the duplicate code in index and tag views
21 21 context['threads'] = None if len(threads) == 0 else threads
22 22 context['form'] = forms.ThreadForm()
23 23 context['tags'] = Tag.objects.get_popular_tags()
24 24 context['theme'] = _get_theme(request)
25 25 context['pages'] = range(Post.objects.get_thread_page_count())
26 26
27 27 return render(request, 'posting_general.html',
28 28 context)
29 29
30 30
31 31 def new_post(request, thread_id=boards.models.NO_PARENT):
32 32 """Add a new post (in thread or as a reply)."""
33 33
34 34 if thread_id == boards.models.NO_PARENT:
35 35 form = ThreadForm(request.POST, request.FILES)
36 36 else:
37 37 form = PostForm(request.POST, request.FILES)
38 38
39 39 if form.is_valid():
40 40 data = form.cleaned_data
41 41 else:
42 42 return redirect(index)
43 43
44 44 title = data['title']
45 45 text = data['text']
46 46
47 47 if 'image' in data.keys():
48 48 image = data['image']
49 49 else:
50 50 image = None
51 51
52 52 ip = _get_client_ip(request)
53 53
54 54 tags = []
55 55
56 56 new_thread = thread_id == boards.models.NO_PARENT
57 57 if new_thread:
58 58 tag_strings = data['tags']
59 59
60 60 if tag_strings:
61 61 tag_strings = tag_strings.split(' ')
62 62 for tag_name in tag_strings:
63 63 tag_name = tag_name.strip()
64 64 if len(tag_name) > 0:
65 65 tag, created = Tag.objects.get_or_create(name=tag_name)
66 66 tags.append(tag)
67 67
68 68 # TODO Add a possibility to define a link image instead of an image file.
69 69 # If a link is given, download the image automatically.
70 70
71 71 post = Post.objects.create_post(title=title, text=text, ip=ip,
72 72 parent_id=thread_id, image=image,
73 73 tags=tags)
74 74
75 75 thread_to_show = (post.id if new_thread else thread_id)
76 76
77 77 if new_thread:
78 78 return redirect(thread, post_id=thread_to_show)
79 79 else:
80 80 return redirect(reverse(thread,
81 81 kwargs={'post_id': thread_to_show}) + '#'
82 82 + str(post.id))
83 83
84 84
85 85 def tag(request, tag_name, page=0):
86 86 """Get all tag threads (posts without a parent)."""
87 87
88 88 tag = Tag.objects.get(name=tag_name)
89 89 threads = Post.objects.get_threads(tag=tag, page=int(page))
90 90
91 91 if request.method == 'POST':
92 92 return new_post(request)
93 93 else:
94 94 context = RequestContext(request)
95 95 context['threads'] = None if len(threads) == 0 else threads
96 96 context['tag'] = tag_name
97 97 context['tags'] = Tag.objects.get_popular_tags()
98 98 context['theme'] = _get_theme(request)
99 99 context['pages'] = range(Post.objects.get_thread_page_count(tag=tag))
100 100
101 101 context['form'] = forms.ThreadForm(initial={'tags': tag_name})
102 102
103 103 return render(request, 'posting_general.html',
104 104 context)
105 105
106 106
107 107 def thread(request, post_id):
108 108 """Get all thread posts"""
109 109
110 110 if request.method == 'POST':
111 111 return new_post(request, post_id)
112 112 else:
113 # TODO Show 404 if there is no such thread
114 113 posts = Post.objects.get_thread(post_id)
115 114
116 115 context = RequestContext(request)
117 116
118 117 context['posts'] = posts
119 118 context['form'] = forms.PostForm()
120 119 context['tags'] = Tag.objects.get_popular_tags()
121 120 context['theme'] = _get_theme(request)
122 121
123 122 return render(request, 'thread.html', context)
124 123
125 124
126 125 def login(request):
127 126 """Log in as admin"""
128 127
129 128 if 'name' in request.POST and 'password' in request.POST:
130 129 request.session['admin'] = False
131 130
132 131 isAdmin = len(Admin.objects.filter(name=request.POST['name'],
133 132 password=request.POST[
134 133 'password'])) > 0
135 134
136 135 if isAdmin:
137 136 request.session['admin'] = True
138 137
139 138 response = HttpResponseRedirect('/')
140 139
141 140 else:
142 141 response = render(request, 'login.html', {'error': 'Login error'})
143 142 else:
144 143 response = render(request, 'login.html', {})
145 144
146 145 return response
147 146
148 147
149 148 def logout(request):
150 149 request.session['admin'] = False
151 150 return HttpResponseRedirect('/')
152 151
153 152
154 153 def settings(request):
155 154 context = RequestContext(request)
156 155
157 156 if request.method == 'POST':
158 157 form = SettingsForm(request.POST)
159 158 if form.is_valid():
160 159 selected_theme = form.cleaned_data['theme']
161 160 request.session['theme'] = selected_theme
162 161
163 162 return redirect(settings)
164 163 else:
165 164 selected_theme = _get_theme(request)
166 165 form = SettingsForm(initial={'theme': selected_theme})
167 166 context['form'] = form
168 167 context['tags'] = Tag.objects.get_popular_tags()
169 168 context['theme'] = _get_theme(request)
170 169
171 170 return render(request, 'settings.html', context)
172 171
173 172
174 173 def all_tags(request):
175 174 context = RequestContext(request)
176 175 context['tags'] = Tag.objects.get_popular_tags()
177 176 context['theme'] = _get_theme(request)
178 177 context['all_tags'] = Tag.objects.get_not_empty_tags()
179 178
180 179 return render(request, 'tags.html', context)
181 180
182 181
183 182 def _get_theme(request):
184 183 return request.session.get('theme', neboard.settings.DEFAULT_THEME)
185 184
186 185
187 186 def _get_client_ip(request):
188 187 x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
189 188 if x_forwarded_for:
190 189 ip = x_forwarded_for.split(',')[-1].strip()
191 190 else:
192 191 ip = request.META.get('REMOTE_ADDR')
193 192 return ip No newline at end of file
General Comments 0
You need to be logged in to leave comments. Login now