##// END OF EJS Templates
Merged in 1.7 branch
neko259 -
r573:4bac2f37 merge 1.7 default
parent child Browse files
Show More
@@ -0,0 +1,38 b''
1 {% extends "boards/base.html" %}
2
3 {% load i18n %}
4 {% load cache %}
5 {% load static from staticfiles %}
6 {% load board %}
7
8 {% block head %}
9 <title>#{{ post.id }} - {{ site_name }}</title>
10 {% endblock %}
11
12 {% block content %}
13 {% spaceless %}
14
15 {% post_view post moderator=moderator %}
16
17 {% if post.is_opening %}
18 <div class="post">
19 {% trans 'Tags:' %}
20 {% for tag in post.thread_new.get_tags %}
21 <a class="tag" href={% url 'tag' tag.name %}>#{{ tag.name }}</a>
22 <a href="?method=delete_tag&tag={{ tag.name }}">[X]</a>
23 {% if not forloop.last %},{% endif %}
24 {% endfor %}
25 <div class="post-form-w">
26 <form id="form" enctype="multipart/form-data"
27 method="post">{% csrf_token %}
28 {{ tag_form.as_div }}
29 <div class="form-submit">
30 <input type="submit" value="{% trans "Add tag" %}"/>
31 </div>
32 </form>
33 </div>
34 </div>
35 {% endif %}
36
37 {% endspaceless %}
38 {% endblock %}
@@ -0,0 +1,12 b''
1 from django.shortcuts import render
2
3 from boards.views.base import BaseBoardView
4 from boards.models.tag import Tag
5
6 class AllTagsView(BaseBoardView):
7
8 def get(self, request):
9 context = self.get_context_data(request=request)
10 context['all_tags'] = Tag.objects.get_not_empty_tags()
11
12 return render(request, 'boards/tags.html', context)
@@ -0,0 +1,125 b''
1 import string
2
3 from django.core.paginator import Paginator
4 from django.core.urlresolvers import reverse
5 from django.db import transaction
6 from django.shortcuts import render, redirect
7
8 from boards import utils
9 from boards.forms import ThreadForm, PlainErrorList
10 from boards.models import Post, Thread, Ban, Tag
11 from boards.views.banned import BannedView
12 from boards.views.base import BaseBoardView, PARAMETER_FORM
13 from boards.views.posting_mixin import PostMixin
14 import neboard
15
16 PARAMETER_CURRENT_PAGE = 'current_page'
17
18 PARAMETER_PAGINATOR = 'paginator'
19
20 PARAMETER_THREADS = 'threads'
21
22 TEMPLATE = 'boards/posting_general.html'
23 DEFAULT_PAGE = 1
24
25
26 class AllThreadsView(PostMixin, BaseBoardView):
27
28 def get(self, request, page=DEFAULT_PAGE, form=None):
29 context = self.get_context_data(request=request)
30
31 if not form:
32 form = ThreadForm(error_class=PlainErrorList)
33
34 paginator = Paginator(self.get_threads(),
35 neboard.settings.THREADS_PER_PAGE)
36
37 threads = paginator.page(page).object_list
38
39 context[PARAMETER_THREADS] = threads
40 context[PARAMETER_FORM] = form
41
42 self._get_page_context(paginator, context, page)
43
44 return render(request, TEMPLATE, context)
45
46 def post(self, request, page=DEFAULT_PAGE):
47 context = self.get_context_data(request=request)
48
49 form = ThreadForm(request.POST, request.FILES,
50 error_class=PlainErrorList)
51 form.session = request.session
52
53 if form.is_valid():
54 return self._new_post(request, form)
55 if form.need_to_ban:
56 # Ban user because he is suspected to be a bot
57 self._ban_current_user(request)
58
59 return self.get(request, page, form)
60
61 @staticmethod
62 def _get_page_context(paginator, context, page):
63 """
64 Get pagination context variables
65 """
66
67 context[PARAMETER_PAGINATOR] = paginator
68 context[PARAMETER_CURRENT_PAGE] = paginator.page(int(page))
69
70 # TODO This method should be refactored
71 @transaction.atomic
72 def _new_post(self, request, form, opening_post=None, html_response=True):
73 """
74 Add a new thread opening post.
75 """
76
77 ip = utils.get_client_ip(request)
78 is_banned = Ban.objects.filter(ip=ip).exists()
79
80 if is_banned:
81 if html_response:
82 return redirect(BannedView().as_view())
83 else:
84 return
85
86 data = form.cleaned_data
87
88 title = data['title']
89 text = data['text']
90
91 text = self._remove_invalid_links(text)
92
93 if 'image' in data.keys():
94 image = data['image']
95 else:
96 image = None
97
98 tags = []
99
100 tag_strings = data['tags']
101
102 if tag_strings:
103 tag_strings = tag_strings.split(' ')
104 for tag_name in tag_strings:
105 tag_name = string.lower(tag_name.strip())
106 if len(tag_name) > 0:
107 tag, created = Tag.objects.get_or_create(name=tag_name)
108 tags.append(tag)
109
110 post = Post.objects.create_post(title=title, text=text, ip=ip,
111 image=image, tags=tags,
112 user=self._get_user(request))
113
114 thread_to_show = (opening_post.id if opening_post else post.id)
115
116 if html_response:
117 if opening_post:
118 return redirect(
119 reverse('thread', kwargs={'post_id': thread_to_show}) +
120 '#' + str(post.id))
121 else:
122 return redirect('thread', post_id=thread_to_show)
123
124 def get_threads(self):
125 return Thread.objects.filter(archived=False).order_by('-bump_time')
@@ -0,0 +1,10 b''
1 from boards.models import Thread
2 from boards.views.all_threads import AllThreadsView
3
4 __author__ = 'neko259'
5
6
7 class ArchiveView(AllThreadsView):
8
9 def get_threads(self):
10 return Thread.objects.filter(archived=True).order_by('-bump_time')
@@ -0,0 +1,13 b''
1 from django.shortcuts import render
2
3 from boards.authors import authors
4 from boards.views.base import BaseBoardView
5
6
7 class AuthorsView(BaseBoardView):
8
9 def get(self, request):
10 context = self.get_context_data(request=request)
11 context['authors'] = authors
12
13 return render(request, 'boards/authors.html', context)
@@ -0,0 +1,23 b''
1 from django.db import transaction
2 from django.shortcuts import get_object_or_404
3
4 from boards.views.base import BaseBoardView
5 from boards.models import Post, Ban
6 from boards.views.mixins import RedirectNextMixin
7
8
9 class BanUserView(BaseBoardView, RedirectNextMixin):
10
11 @transaction.atomic
12 def get(self, request, post_id):
13 user = self._get_user(request)
14 post = get_object_or_404(Post, id=post_id)
15
16 if user.is_moderator():
17 # TODO Show confirmation page before ban
18 ban, created = Ban.objects.get_or_create(ip=post.poster_ip)
19 if created:
20 ban.reason = 'Banned for post ' + str(post_id)
21 ban.save()
22
23 return self.redirect_to_next(request)
@@ -0,0 +1,16 b''
1 from django.shortcuts import get_object_or_404, render
2 from boards import utils
3 from boards.models import Ban
4 from boards.views.base import BaseBoardView
5
6
7 class BannedView(BaseBoardView):
8
9 def get(self, request):
10 """Show the page that notifies that user is banned"""
11
12 context = self.get_context_data(request=request)
13
14 ban = get_object_or_404(Ban, ip=utils.get_client_ip(request))
15 context['ban_reason'] = ban.reason
16 return render(request, 'boards/staticpages/banned.html', context)
@@ -0,0 +1,125 b''
1 from datetime import datetime, timedelta
2 import hashlib
3 from django.db import transaction
4 from django.db.models import Count
5 from django.template import RequestContext
6 from django.utils import timezone
7 from django.views.generic import View
8 from boards import utils
9 from boards.models import User, Post
10 from boards.models.post import SETTING_MODERATE
11 from boards.models.user import RANK_USER, Ban
12 import neboard
13
14 BAN_REASON_SPAM = 'Autoban: spam bot'
15
16 OLD_USER_AGE_DAYS = 90
17
18 PARAMETER_FORM = 'form'
19
20
21 class BaseBoardView(View):
22
23 def get_context_data(self, **kwargs):
24 request = kwargs['request']
25 context = self._default_context(request)
26
27 context['version'] = neboard.settings.VERSION
28 context['site_name'] = neboard.settings.SITE_NAME
29
30 return context
31
32 def _default_context(self, request):
33 """Create context with default values that are used in most views"""
34
35 context = RequestContext(request)
36
37 user = self._get_user(request)
38 context['user'] = user
39 context['tags'] = user.get_sorted_fav_tags()
40 context['posts_per_day'] = float(Post.objects.get_posts_per_day())
41
42 theme = self._get_theme(request, user)
43 context['theme'] = theme
44 context['theme_css'] = 'css/' + theme + '/base_page.css'
45
46 # This shows the moderator panel
47 moderate = user.get_setting(SETTING_MODERATE)
48 if moderate == 'True':
49 context['moderator'] = user.is_moderator()
50 else:
51 context['moderator'] = False
52
53 return context
54
55 def _get_user(self, request):
56 """
57 Get current user from the session. If the user does not exist, create
58 a new one.
59 """
60
61 session = request.session
62 if not 'user_id' in session:
63 request.session.save()
64
65 md5 = hashlib.md5()
66 md5.update(session.session_key)
67 new_id = md5.hexdigest()
68
69 while User.objects.filter(user_id=new_id).exists():
70 md5.update(str(timezone.now()))
71 new_id = md5.hexdigest()
72
73 time_now = timezone.now()
74 user = User.objects.create(user_id=new_id, rank=RANK_USER,
75 registration_time=time_now)
76
77 self._delete_old_users()
78
79 session['user_id'] = user.id
80 else:
81 user = User.objects.get(id=session['user_id'])
82
83 return user
84
85 def _get_theme(self, request, user=None):
86 """
87 Get user's CSS theme
88 """
89
90 if not user:
91 user = self._get_user(request)
92 theme = user.get_setting('theme')
93 if not theme:
94 theme = neboard.settings.DEFAULT_THEME
95
96 return theme
97
98 def _delete_old_users(self):
99 """
100 Delete users with no favorite tags and posted messages. These can be spam
101 bots or just old user accounts
102 """
103
104 old_registration_date = datetime.now().date() - timedelta(
105 OLD_USER_AGE_DAYS)
106
107 for user in User.objects.annotate(tags_count=Count('fav_tags')).filter(
108 tags_count=0).filter(
109 registration_time__lt=old_registration_date):
110 if not Post.objects.filter(user=user).exists():
111 user.delete()
112
113 @transaction.atomic
114 def _ban_current_user(self, request):
115 """
116 Add current user to the IP ban list
117 """
118
119 ip = utils.get_client_ip(request)
120 ban, created = Ban.objects.get_or_create(ip=ip)
121 if created:
122 ban.can_read = False
123 ban.reason = BAN_REASON_SPAM
124 ban.save()
125
@@ -0,0 +1,26 b''
1 from django.shortcuts import redirect, get_object_or_404
2 from django.db import transaction
3
4 from boards.views.base import BaseBoardView
5 from boards.views.mixins import RedirectNextMixin
6 from boards.models import Post
7
8
9 class DeletePostView(BaseBoardView, RedirectNextMixin):
10
11 @transaction.atomic
12 def get(self, request, post_id):
13 user = self._get_user(request)
14 post = get_object_or_404(Post, id=post_id)
15
16 opening_post = post.is_opening()
17
18 if user.is_moderator():
19 # TODO Show confirmation page before deletion
20 Post.objects.delete_post(post)
21
22 if not opening_post:
23 thread = post.thread_new
24 return redirect('thread', post_id=thread.get_opening_post().id)
25 else:
26 return self.redirect_to_next(request)
@@ -0,0 +1,30 b''
1 from django.shortcuts import render, redirect
2 from boards.forms import LoginForm, PlainErrorList
3 from boards.models import User
4 from boards.views.base import BaseBoardView, PARAMETER_FORM
5
6 __author__ = 'neko259'
7
8
9 class LoginView(BaseBoardView):
10
11 def get(self, request, form=None):
12 context = self.get_context_data(request=request)
13
14 if not form:
15 form = LoginForm()
16 context[PARAMETER_FORM] = form
17
18 return render(request, 'boards/login.html', context)
19
20 def post(self, request):
21 form = LoginForm(request.POST, request.FILES,
22 error_class=PlainErrorList)
23 form.session = request.session
24
25 if form.is_valid():
26 user = User.objects.get(user_id=form.cleaned_data['user_id'])
27 request.session['user_id'] = user.id
28 return redirect('index')
29 else:
30 return self.get(request, form)
@@ -0,0 +1,39 b''
1 PARAMETER_METHOD = 'method'
2
3 from django.shortcuts import redirect
4 from django.http import HttpResponseRedirect
5
6
7 class RedirectNextMixin:
8
9 def redirect_to_next(self, request):
10 """
11 If a 'next' parameter was specified, redirect to the next page. This
12 is used when the user is required to return to some page after the
13 current view has finished its work.
14 """
15
16 if 'next' in request.GET:
17 next_page = request.GET['next']
18 return HttpResponseRedirect(next_page)
19 else:
20 return redirect('index')
21
22
23 class DispatcherMixin:
24 """
25 This class contains a dispather method that can run a method specified by
26 'method' request parameter.
27 """
28
29 def dispatch_method(self, *args, **kwargs):
30 request = args[0]
31
32 method_name = None
33 if PARAMETER_METHOD in request.GET:
34 method_name = request.GET[PARAMETER_METHOD]
35 elif PARAMETER_METHOD in request.POST:
36 method_name = request.POST[PARAMETER_METHOD]
37
38 if method_name:
39 return getattr(self, method_name)(*args, **kwargs)
@@ -0,0 +1,13 b''
1 from django.shortcuts import render
2
3 from boards.views.base import BaseBoardView
4
5
6 class NotFoundView(BaseBoardView):
7 """
8 Page 404 (not found)
9 """
10
11 def get(self, request):
12 context = self.get_context_data(request=request)
13 return render(request, 'boards/404.html', context)
@@ -0,0 +1,57 b''
1 from django.shortcuts import render, get_object_or_404, redirect
2
3 from boards.views.base import BaseBoardView
4 from boards.views.mixins import DispatcherMixin
5 from boards.models.post import Post
6 from boards.models.tag import Tag
7 from boards.forms import AddTagForm, PlainErrorList
8
9 class PostAdminView(BaseBoardView, DispatcherMixin):
10
11 def get(self, request, post_id, form=None):
12 user = self._get_user(request)
13 if not user.is_moderator:
14 redirect('index')
15
16 post = get_object_or_404(Post, id=post_id)
17
18 if not form:
19 dispatch_result = self.dispatch_method(request, post)
20 if dispatch_result:
21 return dispatch_result
22 form = AddTagForm()
23
24 context = self.get_context_data(request=request)
25
26 context['post'] = post
27
28 context['tag_form'] = form
29
30 return render(request, 'boards/post_admin.html', context)
31
32 def post(self, request, post_id):
33 user = self._get_user(request)
34 if not user.is_moderator:
35 redirect('index')
36
37 post = get_object_or_404(Post, id=post_id)
38 return self.dispatch_method(request, post)
39
40 def delete_tag(self, request, post):
41 tag_name = request.GET['tag']
42 tag = get_object_or_404(Tag, name=tag_name)
43
44 post.remove_tag(tag)
45
46 return redirect('post_admin', post.id)
47
48 def add_tag(self, request, post):
49 form = AddTagForm(request.POST, error_class=PlainErrorList)
50 if form.is_valid():
51 tag_name = form.cleaned_data['tag']
52 tag, created = Tag.objects.get_or_create(name=tag_name)
53
54 post.add_tag(tag)
55 return redirect('post_admin', post.id)
56 else:
57 return self.get(request, post.id, form)
@@ -0,0 +1,25 b''
1 import re
2 import string
3
4 from boards.models import Post
5 from boards.models.post import REGEX_REPLY
6
7 REFLINK_PREFIX = '>>'
8
9
10 class PostMixin:
11
12 @staticmethod
13 def _remove_invalid_links(text):
14 """
15 Replace invalid links in posts so that they won't be parsed.
16 Invalid links are links to non-existent posts
17 """
18
19 for reply_number in re.finditer(REGEX_REPLY, text):
20 post_id = reply_number.group(1)
21 post = Post.objects.filter(id=post_id)
22 if not post.exists():
23 text = string.replace(text, REFLINK_PREFIX + post_id, post_id)
24
25 return text
@@ -0,0 +1,52 b''
1 from django.db import transaction
2 from django.shortcuts import render, redirect
3
4 from boards.views.base import BaseBoardView, PARAMETER_FORM
5 from boards.forms import SettingsForm, ModeratorSettingsForm, PlainErrorList
6 from boards.models.post import SETTING_MODERATE
7
8
9 class SettingsView(BaseBoardView):
10
11 def get(self, request):
12 context = self.get_context_data(request=request)
13 user = context['user']
14 is_moderator = user.is_moderator()
15
16 selected_theme = context['theme']
17
18 if is_moderator:
19 form = ModeratorSettingsForm(initial={
20 'theme': selected_theme,
21 'moderate': context['moderator']
22 }, error_class=PlainErrorList)
23 else:
24 form = SettingsForm(initial={'theme': selected_theme},
25 error_class=PlainErrorList)
26
27 context[PARAMETER_FORM] = form
28
29 return render(request, 'boards/settings.html', context)
30
31 def post(self, request):
32 context = self.get_context_data(request=request)
33 user = context['user']
34 is_moderator = user.is_moderator()
35
36 with transaction.atomic():
37 if is_moderator:
38 form = ModeratorSettingsForm(request.POST,
39 error_class=PlainErrorList)
40 else:
41 form = SettingsForm(request.POST, error_class=PlainErrorList)
42
43 if form.is_valid():
44 selected_theme = form.cleaned_data['theme']
45
46 user.save_setting('theme', selected_theme)
47
48 if is_moderator:
49 moderate = form.cleaned_data['moderate']
50 user.save_setting(SETTING_MODERATE, moderate)
51
52 return redirect('settings')
@@ -0,0 +1,13 b''
1 from django.shortcuts import render
2
3 from boards.views.base import BaseBoardView
4
5 class StaticPageView(BaseBoardView):
6
7 def get(request, name):
8 """
9 Show a static page that needs only tags list and a CSS
10 """
11
12 context = self.get_context_data(request=request)
13 return render(request, 'boards/staticpages/' + name + '.html', context)
@@ -0,0 +1,51 b''
1 from django.shortcuts import get_object_or_404
2 from boards.models import Tag, Post
3 from boards.views.all_threads import AllThreadsView, DEFAULT_PAGE
4 from boards.views.mixins import DispatcherMixin, RedirectNextMixin
5
6 __author__ = 'neko259'
7
8
9 class TagView(AllThreadsView, DispatcherMixin, RedirectNextMixin):
10
11 tag_name = None
12
13 def get_threads(self):
14 tag = get_object_or_404(Tag, name=self.tag_name)
15
16 return tag.threads.filter(archived=False).order_by('-bump_time')
17
18 def get_context_data(self, **kwargs):
19 context = super(TagView, self).get_context_data(**kwargs)
20
21 tag = get_object_or_404(Tag, name=self.tag_name)
22 context['tag'] = tag
23
24 return context
25
26 def get(self, request, tag_name, page=DEFAULT_PAGE):
27 self.tag_name = tag_name
28
29 dispatch_result = self.dispatch_method(request)
30 if dispatch_result:
31 return dispatch_result
32 else:
33 return super(TagView, self).get(request, page)
34
35 def subscribe(self, request):
36 user = self._get_user(request)
37 tag = get_object_or_404(Tag, name=self.tag_name)
38
39 if not tag in user.fav_tags.all():
40 user.add_tag(tag)
41
42 return self.redirect_to_next(request)
43
44 def unsubscribe(self, request):
45 user = self._get_user(request)
46 tag = get_object_or_404(Tag, name=self.tag_name)
47
48 if tag in user.fav_tags.all():
49 user.remove_tag(tag)
50
51 return self.redirect_to_next(request)
@@ -0,0 +1,121 b''
1 import string
2 from django.core.urlresolvers import reverse
3 from django.db import transaction
4 from django.http import Http404
5 from django.shortcuts import get_object_or_404, render, redirect
6 from boards import utils
7 from boards.forms import PostForm, PlainErrorList
8 from boards.models import Post, Ban, Tag
9 from boards.views.banned import BannedView
10 from boards.views.base import BaseBoardView, PARAMETER_FORM
11 from boards.views.posting_mixin import PostMixin
12 import neboard
13
14 MODE_GALLERY = 'gallery'
15 MODE_NORMAL = 'normal'
16
17
18 class ThreadView(BaseBoardView, PostMixin):
19
20 def get(self, request, post_id, mode=MODE_NORMAL, form=None):
21 opening_post = get_object_or_404(Post, id=post_id)
22
23 # If this is not OP, don't show it as it is
24 if not opening_post.is_opening():
25 raise Http404
26
27 if not form:
28 form = PostForm(error_class=PlainErrorList)
29
30 thread_to_show = opening_post.thread_new
31
32 context = self.get_context_data(request=request)
33
34 context[PARAMETER_FORM] = form
35 context["last_update"] = utils.datetime_to_epoch(
36 thread_to_show.last_edit_time)
37 context["thread"] = thread_to_show
38
39 if MODE_NORMAL == mode:
40 context['bumpable'] = thread_to_show.can_bump()
41 if context['bumpable']:
42 context['posts_left'] = neboard.settings.MAX_POSTS_PER_THREAD \
43 - thread_to_show.get_reply_count()
44 context['bumplimit_progress'] = str(
45 float(context['posts_left']) /
46 neboard.settings.MAX_POSTS_PER_THREAD * 100)
47
48 context['opening_post'] = thread_to_show.get_opening_post()
49
50 document = 'boards/thread.html'
51 elif MODE_GALLERY == mode:
52 posts = thread_to_show.get_replies()
53 context['posts'] = posts.filter(image_width__gt=0)
54
55 document = 'boards/thread_gallery.html'
56 else:
57 raise Http404
58
59 return render(request, document, context)
60
61 def post(self, request, post_id, mode=MODE_NORMAL):
62 opening_post = get_object_or_404(Post, id=post_id)
63
64 # If this is not OP, don't show it as it is
65 if not opening_post.is_opening():
66 raise Http404
67
68 if not opening_post.thread_new.archived:
69 form = PostForm(request.POST, request.FILES,
70 error_class=PlainErrorList)
71 form.session = request.session
72
73 if form.is_valid():
74 return self.new_post(request, form, opening_post)
75 if form.need_to_ban:
76 # Ban user because he is suspected to be a bot
77 self._ban_current_user(request)
78
79 return self.get(request, post_id, mode, form)
80
81 @transaction.atomic
82 def new_post(self, request, form, opening_post=None, html_response=True):
83 """Add a new post (in thread or as a reply)."""
84
85 ip = utils.get_client_ip(request)
86 is_banned = Ban.objects.filter(ip=ip).exists()
87
88 if is_banned:
89 if html_response:
90 return redirect(BannedView().as_view())
91 else:
92 return
93
94 data = form.cleaned_data
95
96 title = data['title']
97 text = data['text']
98
99 text = self._remove_invalid_links(text)
100
101 if 'image' in data.keys():
102 image = data['image']
103 else:
104 image = None
105
106 tags = []
107
108 post_thread = opening_post.thread_new
109
110 post = Post.objects.create_post(title=title, text=text, ip=ip,
111 thread=post_thread, image=image,
112 tags=tags,
113 user=self._get_user(request))
114
115 thread_to_show = (opening_post.id if opening_post else post.id)
116
117 if html_response:
118 if opening_post:
119 return redirect(reverse(
120 'thread',
121 kwargs={'post_id': thread_to_show}) + '#' + str(post.id))
@@ -0,0 +1,8 b''
1 pillow
2 django>=1.6
3 django_cleanup
4 django-markupfield
5 markdown
6 python-markdown
7 django-simple-captcha
8 line-profiler
@@ -26,6 +26,11 b" ERROR_IMAGE_DUPLICATE = _('Such image wa"
26
26
27 LABEL_TITLE = _('Title')
27 LABEL_TITLE = _('Title')
28 LABEL_TEXT = _('Text')
28 LABEL_TEXT = _('Text')
29 LABEL_TAG = _('Tag')
30
31 TAG_MAX_LENGTH = 20
32
33 REGEX_TAG = ur'^[\w\d]+$'
29
34
30
35
31 class FormatPanel(forms.Textarea):
36 class FormatPanel(forms.Textarea):
@@ -311,3 +316,24 b' class LoginForm(NeboardForm):'
311 cleaned_data = super(LoginForm, self).clean()
316 cleaned_data = super(LoginForm, self).clean()
312
317
313 return cleaned_data
318 return cleaned_data
319
320
321 class AddTagForm(NeboardForm):
322
323 tag = forms.CharField(max_length=TAG_MAX_LENGTH, label=LABEL_TAG)
324 method = forms.CharField(widget=forms.HiddenInput(), initial='add_tag')
325
326 def clean_tag(self):
327 tag = self.cleaned_data['tag']
328
329 regex_tag = re.compile(REGEX_TAG, re.UNICODE)
330 if not regex_tag.match(tag):
331 raise forms.ValidationError(_('Inappropriate characters in tags.'))
332
333 return tag
334
335 def clean(self):
336 cleaned_data = super(AddTagForm, self).clean()
337
338 return cleaned_data
339
1 NO CONTENT: modified file, binary diff hidden
NO CONTENT: modified file, binary diff hidden
@@ -7,7 +7,7 b' msgid ""'
7 msgstr ""
7 msgstr ""
8 "Project-Id-Version: PACKAGE VERSION\n"
8 "Project-Id-Version: PACKAGE VERSION\n"
9 "Report-Msgid-Bugs-To: \n"
9 "Report-Msgid-Bugs-To: \n"
10 "POT-Creation-Date: 2014-01-15 10:46+0200\n"
10 "POT-Creation-Date: 2014-01-22 13:07+0200\n"
11 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
11 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
12 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
12 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
13 "Language-Team: LANGUAGE <LL@li.org>\n"
13 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -58,63 +58,67 b' msgstr "\xd0\x97\xd0\xb0\xd0\xb3\xd0\xbe\xd0\xbb\xd0\xbe\xd0\xb2\xd0\xbe\xd0\xba"'
58 msgid "Text"
58 msgid "Text"
59 msgstr "Текст"
59 msgstr "Текст"
60
60
61 #: forms.py:89
61 #: forms.py:29
62 msgid "Tag"
63 msgstr "Тег"
64
65 #: forms.py:106
62 msgid "Image"
66 msgid "Image"
63 msgstr "Изображение"
67 msgstr "Изображение"
64
68
65 #: forms.py:92
69 #: forms.py:109
66 msgid "e-mail"
70 msgid "e-mail"
67 msgstr ""
71 msgstr ""
68
72
69 #: forms.py:103
73 #: forms.py:120
70 #, python-format
74 #, python-format
71 msgid "Title must have less than %s characters"
75 msgid "Title must have less than %s characters"
72 msgstr "Заголовок должен иметь меньше %s символов"
76 msgstr "Заголовок должен иметь меньше %s символов"
73
77
74 #: forms.py:112
78 #: forms.py:129
75 #, python-format
79 #, python-format
76 msgid "Text must have less than %s characters"
80 msgid "Text must have less than %s characters"
77 msgstr "Текст должен быть короче %s символов"
81 msgstr "Текст должен быть короче %s символов"
78
82
79 #: forms.py:123
83 #: forms.py:140
80 #, python-format
84 #, python-format
81 msgid "Image must be less than %s bytes"
85 msgid "Image must be less than %s bytes"
82 msgstr "Изображение должно быть менее %s байт"
86 msgstr "Изображение должно быть менее %s байт"
83
87
84 #: forms.py:158
88 #: forms.py:175
85 msgid "Either text or image must be entered."
89 msgid "Either text or image must be entered."
86 msgstr "Текст или картинка должны быть введены."
90 msgstr "Текст или картинка должны быть введены."
87
91
88 #: forms.py:171
92 #: forms.py:188
89 #, python-format
93 #, python-format
90 msgid "Wait %s seconds after last posting"
94 msgid "Wait %s seconds after last posting"
91 msgstr "Подождите %s секунд после последнего постинга"
95 msgstr "Подождите %s секунд после последнего постинга"
92
96
93 #: forms.py:187 templates/boards/tags.html:6 templates/boards/rss/post.html:10
97 #: forms.py:204 templates/boards/tags.html:6 templates/boards/rss/post.html:10
94 msgid "Tags"
98 msgid "Tags"
95 msgstr "Теги"
99 msgstr "Теги"
96
100
97 #: forms.py:195
101 #: forms.py:212 forms.py:331
98 msgid "Inappropriate characters in tags."
102 msgid "Inappropriate characters in tags."
99 msgstr "Недопустимые символы в тегах."
103 msgstr "Недопустимые символы в тегах."
100
104
101 #: forms.py:223 forms.py:244
105 #: forms.py:240 forms.py:261
102 msgid "Captcha validation failed"
106 msgid "Captcha validation failed"
103 msgstr "Проверка капчи провалена"
107 msgstr "Проверка капчи провалена"
104
108
105 #: forms.py:250
109 #: forms.py:267
106 msgid "Theme"
110 msgid "Theme"
107 msgstr "Тема"
111 msgstr "Тема"
108
112
109 #: forms.py:255
113 #: forms.py:272
110 msgid "Enable moderation panel"
114 msgid "Enable moderation panel"
111 msgstr "Включить панель модерации"
115 msgstr "Включить панель модерации"
112
116
113 #: forms.py:270
117 #: forms.py:287
114 msgid "No such user found"
118 msgid "No such user found"
115 msgstr "Данный пользователь не найден"
119 msgstr "Данный пользователь не найден"
116
120
117 #: forms.py:284
121 #: forms.py:301
118 #, python-format
122 #, python-format
119 msgid "Wait %s minutes after last login"
123 msgid "Wait %s minutes after last login"
120 msgstr "Подождите %s минут после последнего входа"
124 msgstr "Подождите %s минут после последнего входа"
@@ -127,69 +131,19 b' msgstr "\xd0\x9d\xd0\xb5 \xd0\xbd\xd0\xb0\xd0\xb9\xd0\xb4\xd0\xb5\xd0\xbd\xd0\xbe"'
127 msgid "This page does not exist"
131 msgid "This page does not exist"
128 msgstr "Этой страницы не существует"
132 msgstr "Этой страницы не существует"
129
133
130 #: templates/boards/archive.html:9 templates/boards/base.html:51
131 msgid "Archive"
132 msgstr "Архив"
133
134 #: templates/boards/archive.html:39 templates/boards/posting_general.html:64
135 msgid "Previous page"
136 msgstr "Предыдущая страница"
137
138 #: templates/boards/archive.html:68
139 msgid "Open"
140 msgstr "Открыть"
141
142 #: templates/boards/archive.html:74 templates/boards/post.html:37
143 #: templates/boards/posting_general.html:103 templates/boards/thread.html:69
144 msgid "Delete"
145 msgstr "Удалить"
146
147 #: templates/boards/archive.html:78 templates/boards/post.html:40
148 #: templates/boards/posting_general.html:107 templates/boards/thread.html:72
149 msgid "Ban IP"
150 msgstr "Заблокировать IP"
151
152 #: templates/boards/archive.html:87 templates/boards/post.html:53
153 #: templates/boards/posting_general.html:116
154 #: templates/boards/posting_general.html:180 templates/boards/thread.html:81
155 msgid "Replies"
156 msgstr "Ответы"
157
158 #: templates/boards/archive.html:96 templates/boards/posting_general.html:125
159 #: templates/boards/thread.html:138 templates/boards/thread_gallery.html:58
160 msgid "images"
161 msgstr "изображений"
162
163 #: templates/boards/archive.html:97 templates/boards/thread.html:137
164 #: templates/boards/thread_gallery.html:57
165 msgid "replies"
166 msgstr "ответов"
167
168 #: templates/boards/archive.html:116 templates/boards/posting_general.html:203
169 msgid "Next page"
170 msgstr "Следующая страница"
171
172 #: templates/boards/archive.html:121 templates/boards/posting_general.html:208
173 msgid "No threads exist. Create the first one!"
174 msgstr "Нет тем. Создайте первую!"
175
176 #: templates/boards/archive.html:130 templates/boards/posting_general.html:235
177 msgid "Pages:"
178 msgstr "Страницы: "
179
180 #: templates/boards/authors.html:6 templates/boards/authors.html.py:12
134 #: templates/boards/authors.html:6 templates/boards/authors.html.py:12
181 msgid "Authors"
135 msgid "Authors"
182 msgstr "Авторы"
136 msgstr "Авторы"
183
137
184 #: templates/boards/authors.html:25
138 #: templates/boards/authors.html:26
185 msgid "Distributed under the"
139 msgid "Distributed under the"
186 msgstr "Распространяется под"
140 msgstr "Распространяется под"
187
141
188 #: templates/boards/authors.html:27
142 #: templates/boards/authors.html:28
189 msgid "license"
143 msgid "license"
190 msgstr "лицензией"
144 msgstr "лицензией"
191
145
192 #: templates/boards/authors.html:29
146 #: templates/boards/authors.html:30
193 msgid "Repository"
147 msgid "Repository"
194 msgstr "Репозиторий"
148 msgstr "Репозиторий"
195
149
@@ -205,7 +159,7 b' msgstr "\xd0\x92\xd1\x81\xd0\xb5 \xd1\x82\xd0\xb5\xd0\xbc\xd1\x8b"'
205 msgid "Tag management"
159 msgid "Tag management"
206 msgstr "Управление тегами"
160 msgstr "Управление тегами"
207
161
208 #: templates/boards/base.html:38
162 #: templates/boards/base.html:38 templates/boards/settings.html:7
209 msgid "Settings"
163 msgid "Settings"
210 msgstr "Настройки"
164 msgstr "Настройки"
211
165
@@ -214,6 +168,10 b' msgstr "\xd0\x9d\xd0\xb0\xd1\x81\xd1\x82\xd1\x80\xd0\xbe\xd0\xb9\xd0\xba\xd0\xb8"'
214 msgid "Login"
168 msgid "Login"
215 msgstr "Вход"
169 msgstr "Вход"
216
170
171 #: templates/boards/base.html:51
172 msgid "Archive"
173 msgstr "Архив"
174
217 #: templates/boards/base.html:53
175 #: templates/boards/base.html:53
218 #, python-format
176 #, python-format
219 msgid "Speed: %(ppd)s posts per day"
177 msgid "Speed: %(ppd)s posts per day"
@@ -231,32 +189,81 b' msgstr "ID \xd0\xbf\xd0\xbe\xd0\xbb\xd1\x8c\xd0\xb7\xd0\xbe\xd0\xb2\xd0\xb0\xd1\x82\xd0\xb5\xd0\xbb\xd1\x8f"'
231 msgid "Insert your user id above"
189 msgid "Insert your user id above"
232 msgstr "Вставьте свой ID пользователя выше"
190 msgstr "Вставьте свой ID пользователя выше"
233
191
234 #: templates/boards/posting_general.html:97
192 #: templates/boards/post.html:47
193 msgid "Open"
194 msgstr "Открыть"
195
196 #: templates/boards/post.html:49
235 msgid "Reply"
197 msgid "Reply"
236 msgstr "Ответ"
198 msgstr "Ответ"
237
199
238 #: templates/boards/posting_general.html:142
200 #: templates/boards/post.html:56
201 msgid "Edit"
202 msgstr "Изменить"
203
204 #: templates/boards/post.html:58
205 msgid "Delete"
206 msgstr "Удалить"
207
208 #: templates/boards/post.html:61
209 msgid "Ban IP"
210 msgstr "Заблокировать IP"
211
212 #: templates/boards/post.html:74
213 msgid "Replies"
214 msgstr "Ответы"
215
216 #: templates/boards/post.html:85 templates/boards/thread.html:74
217 #: templates/boards/thread_gallery.html:59
218 msgid "images"
219 msgstr "изображений"
220
221 #: templates/boards/post_admin.html:19
222 msgid "Tags:"
223 msgstr "Теги:"
224
225 #: templates/boards/post_admin.html:30
226 msgid "Add tag"
227 msgstr "Добавить тег"
228
229 #: templates/boards/posting_general.html:64
230 msgid "Previous page"
231 msgstr "Предыдущая страница"
232
233 #: templates/boards/posting_general.html:77
239 #, python-format
234 #, python-format
240 msgid "Skipped %(count)s replies. Open thread to see all replies."
235 msgid "Skipped %(count)s replies. Open thread to see all replies."
241 msgstr "Пропущено %(count)s ответов. Откройте тред, чтобы увидеть все ответы."
236 msgstr "Пропущено %(count)s ответов. Откройте тред, чтобы увидеть все ответы."
242
237
243 #: templates/boards/posting_general.html:214
238 #: templates/boards/posting_general.html:100
239 msgid "Next page"
240 msgstr "Следующая страница"
241
242 #: templates/boards/posting_general.html:105
243 msgid "No threads exist. Create the first one!"
244 msgstr "Нет тем. Создайте первую!"
245
246 #: templates/boards/posting_general.html:111
244 msgid "Create new thread"
247 msgid "Create new thread"
245 msgstr "Создать новую тему"
248 msgstr "Создать новую тему"
246
249
247 #: templates/boards/posting_general.html:218 templates/boards/thread.html:115
250 #: templates/boards/posting_general.html:115 templates/boards/thread.html:50
248 msgid "Post"
251 msgid "Post"
249 msgstr "Отправить"
252 msgstr "Отправить"
250
253
251 #: templates/boards/posting_general.html:222
254 #: templates/boards/posting_general.html:119
252 msgid "Tags must be delimited by spaces. Text or image is required."
255 msgid "Tags must be delimited by spaces. Text or image is required."
253 msgstr ""
256 msgstr ""
254 "Теги должны быть разделены пробелами. Текст или изображение обязательны."
257 "Теги должны быть разделены пробелами. Текст или изображение обязательны."
255
258
256 #: templates/boards/posting_general.html:225 templates/boards/thread.html:119
259 #: templates/boards/posting_general.html:122 templates/boards/thread.html:54
257 msgid "Text syntax"
260 msgid "Text syntax"
258 msgstr "Синтаксис текста"
261 msgstr "Синтаксис текста"
259
262
263 #: templates/boards/posting_general.html:132
264 msgid "Pages:"
265 msgstr "Страницы: "
266
260 #: templates/boards/settings.html:14
267 #: templates/boards/settings.html:14
261 msgid "User:"
268 msgid "User:"
262 msgstr "Пользователь:"
269 msgstr "Пользователь:"
@@ -285,23 +292,27 b' msgstr "\xd0\xa1\xd0\xbe\xd1\x85\xd1\x80\xd0\xb0\xd0\xbd\xd0\xb8\xd1\x82\xd1\x8c"'
285 msgid "No tags found."
292 msgid "No tags found."
286 msgstr "Теги не найдены."
293 msgstr "Теги не найдены."
287
294
288 #: templates/boards/thread.html:19 templates/boards/thread_gallery.html:20
295 #: templates/boards/thread.html:20 templates/boards/thread_gallery.html:21
289 msgid "Normal mode"
296 msgid "Normal mode"
290 msgstr "Нормальный режим"
297 msgstr "Нормальный режим"
291
298
292 #: templates/boards/thread.html:20 templates/boards/thread_gallery.html:21
299 #: templates/boards/thread.html:21 templates/boards/thread_gallery.html:22
293 msgid "Gallery mode"
300 msgid "Gallery mode"
294 msgstr "Режим галереи"
301 msgstr "Режим галереи"
295
302
296 #: templates/boards/thread.html:28
303 #: templates/boards/thread.html:29
297 msgid "posts to bumplimit"
304 msgid "posts to bumplimit"
298 msgstr "сообщений до бамплимита"
305 msgstr "сообщений до бамплимита"
299
306
300 #: templates/boards/thread.html:109
307 #: templates/boards/thread.html:44
301 msgid "Reply to thread"
308 msgid "Reply to thread"
302 msgstr "Ответить в тему"
309 msgstr "Ответить в тему"
303
310
304 #: templates/boards/thread.html:139 templates/boards/thread_gallery.html:59
311 #: templates/boards/thread.html:73 templates/boards/thread_gallery.html:58
312 msgid "replies"
313 msgstr "ответов"
314
315 #: templates/boards/thread.html:75 templates/boards/thread_gallery.html:60
305 msgid "Last update: "
316 msgid "Last update: "
306 msgstr "Последнее обновление: "
317 msgstr "Последнее обновление: "
307
318
@@ -66,11 +66,11 b' class ReflinkPattern(Pattern):'
66
66
67 post = posts[0]
67 post = posts[0]
68 if not post.is_opening():
68 if not post.is_opening():
69 link = reverse(boards.views.thread, kwargs={
69 link = reverse('thread', kwargs={
70 'post_id': post.thread_new.get_opening_post().id})\
70 'post_id': post.thread_new.get_opening_post().id})\
71 + '#' + post_id
71 + '#' + post_id
72 else:
72 else:
73 link = reverse(boards.views.thread, kwargs={'post_id': post_id})
73 link = reverse('thread', kwargs={'post_id': post_id})
74
74
75 ref_element.set('href', link)
75 ref_element.set('href', link)
76 ref_element.text = m.group(2)
76 ref_element.text = m.group(2)
@@ -17,14 +17,14 b' class BanMiddleware:'
17
17
18 def process_view(self, request, view_func, view_args, view_kwargs):
18 def process_view(self, request, view_func, view_args, view_kwargs):
19
19
20 if view_func != views.you_are_banned:
20 if view_func != views.banned.BannedView.as_view:
21 ip = utils.get_client_ip(request)
21 ip = utils.get_client_ip(request)
22 bans = Ban.objects.filter(ip=ip)
22 bans = Ban.objects.filter(ip=ip)
23
23
24 if bans.exists():
24 if bans.exists():
25 ban = bans[0]
25 ban = bans[0]
26 if not ban.can_read:
26 if not ban.can_read:
27 return redirect(views.you_are_banned)
27 return redirect('banned')
28
28
29
29
30 class MinifyHTMLMiddleware(object):
30 class MinifyHTMLMiddleware(object):
@@ -10,7 +10,7 b' import hashlib'
10 from django.core.cache import cache
10 from django.core.cache import cache
11 from django.core.paginator import Paginator
11 from django.core.paginator import Paginator
12
12
13 from django.db import models
13 from django.db import models, transaction
14 from django.http import Http404
14 from django.http import Http404
15 from django.utils import timezone
15 from django.utils import timezone
16 from markupfield.fields import MarkupField
16 from markupfield.fields import MarkupField
@@ -94,13 +94,11 b' class PostManager(models.Manager):'
94 """
94 """
95 Delete post and update or delete its thread
95 Delete post and update or delete its thread
96 """
96 """
97
97
98 thread = post.thread_new
98 thread = post.thread_new
99
99
100 if thread.get_opening_post() == self:
100 if post.is_opening():
101 thread.replies.delete()
101 thread.delete_with_posts()
102
103 thread.delete()
104 else:
102 else:
105 thread.last_edit_time = timezone.now()
103 thread.last_edit_time = timezone.now()
106 thread.save()
104 thread.save()
@@ -115,7 +113,8 b' class PostManager(models.Manager):'
115 posts = self.filter(poster_ip=ip)
113 posts = self.filter(poster_ip=ip)
116 map(self.delete_post, posts)
114 map(self.delete_post, posts)
117
115
118 # TODO Move this method to thread manager
116 # TODO This method may not be needed any more, because django's paginator
117 # is used
119 def get_threads(self, tag=None, page=ALL_PAGES,
118 def get_threads(self, tag=None, page=ALL_PAGES,
120 order_by='-bump_time', archived=False):
119 order_by='-bump_time', archived=False):
121 if tag:
120 if tag:
@@ -197,7 +196,7 b' class PostManager(models.Manager):'
197
196
198 ppd = (sum(posts_per_day for posts_per_day in posts_per_days) /
197 ppd = (sum(posts_per_day for posts_per_day in posts_per_days) /
199 len(posts_per_days))
198 len(posts_per_days))
200 cache.set(CACHE_KEY_PPD, ppd)
199 cache.set(CACHE_KEY_PPD + str(today), ppd)
201 return ppd
200 return ppd
202
201
203
202
@@ -284,6 +283,30 b' class Post(models.Model):'
284 self.image_hash = md5.hexdigest()
283 self.image_hash = md5.hexdigest()
285 super(Post, self).save(*args, **kwargs)
284 super(Post, self).save(*args, **kwargs)
286
285
286 @transaction.atomic
287 def add_tag(self, tag):
288 edit_time = timezone.now()
289
290 thread = self.thread_new
291 thread.add_tag(tag)
292 self.last_edit_time = edit_time
293 self.save()
294
295 thread.last_edit_time = edit_time
296 thread.save()
297
298 @transaction.atomic
299 def remove_tag(self, tag):
300 edit_time = timezone.now()
301
302 thread = self.thread_new
303 thread.tags.remove(tag)
304 self.last_edit_time = edit_time
305 self.save()
306
307 thread.last_edit_time = edit_time
308 thread.save()
309
287
310
288 class Thread(models.Model):
311 class Thread(models.Model):
289
312
@@ -356,6 +379,10 b' class Thread(models.Model):'
356
379
357 return last_replies
380 return last_replies
358
381
382 def get_skipped_replies_count(self):
383 last_replies = self.get_last_replies()
384 return self.get_reply_count() - len(last_replies) - 1
385
359 def get_replies(self):
386 def get_replies(self):
360 """
387 """
361 Get sorted thread posts
388 Get sorted thread posts
@@ -379,7 +406,7 b' class Thread(models.Model):'
379 return self.get_replies()[0]
406 return self.get_replies()[0]
380
407
381 def __unicode__(self):
408 def __unicode__(self):
382 return str(self.get_replies()[0].id)
409 return str(self.id)
383
410
384 def get_pub_time(self):
411 def get_pub_time(self):
385 """
412 """
@@ -13,7 +13,6 b''
13
13
14 .img-full {
14 .img-full {
15 position: fixed;
15 position: fixed;
16 z-index: 9999;
17 background-color: #CCC;
16 background-color: #CCC;
18 border: 1px solid #000;
17 border: 1px solid #000;
19 cursor: pointer;
18 cursor: pointer;
@@ -85,7 +85,10 b' function addImgPreview() {'
85
85
86 return false;
86 return false;
87 }
87 }
88 ).draggable()
88 ).draggable({
89 addClasses: false,
90 stack: '.img-full'
91 })
89 }
92 }
90 else {
93 else {
91 $('#'+thumb_id).remove();
94 $('#'+thumb_id).remove();
@@ -3,7 +3,7 b''
3 {% load i18n %}
3 {% load i18n %}
4
4
5 {% block head %}
5 {% block head %}
6 <title>{% trans 'Login' %}</title>
6 <title>{% trans 'Login' %} - {{ site_name }}</title>
7 {% endblock %}
7 {% endblock %}
8
8
9 {% block content %}
9 {% block content %}
@@ -4,85 +4,102 b''
4
4
5 {% get_current_language as LANGUAGE_CODE %}
5 {% get_current_language as LANGUAGE_CODE %}
6
6
7 {% cache 300 post post.id post.thread_new.last_edit_time truncated moderator LANGUAGE_CODE need_open_link %}
7 {% cache 600 post post.id thread.last_edit_time truncated moderator LANGUAGE_CODE need_open_link %}
8 {% spaceless %}
8
9 {% if post.thread_new.archived %}
9 {% with is_opening=post.is_opening %}
10 <div class="post archive_post" id="{{ post.id }}">
10 {% spaceless %}
11 {% elif post.thread_new.can_bump %}
11 {% with thread=post.thread_new %}
12 <div class="post" id="{{ post.id }}">
12 {% if thread.archived %}
13 {% else %}
13 <div class="post archive_post" id="{{ post.id }}">
14 <div class="post dead_post" id="{{ post.id }}">
14 {% elif thread.can_bump %}
15 {% endif %}
15 <div class="post" id="{{ post.id }}">
16 {% else %}
17 <div class="post dead_post" id="{{ post.id }}">
18 {% endif %}
16
19
17 {% if post.image %}
20 {% if post.image %}
18 <div class="image">
21 <div class="image">
19 <a
22 <a
20 class="thumb"
23 class="thumb"
21 href="{{ post.image.url }}"><img
24 href="{{ post.image.url }}"><img
22 src="{{ post.image.url_200x150 }}"
25 src="{{ post.image.url_200x150 }}"
23 alt="{{ post.id }}"
26 alt="{{ post.id }}"
24 width="{{ post.image_pre_width }}"
27 width="{{ post.image_pre_width }}"
25 height="{{ post.image_pre_height }}"
28 height="{{ post.image_pre_height }}"
26 data-width="{{ post.image_width }}"
29 data-width="{{ post.image_width }}"
27 data-height="{{ post.image_height }}"/>
30 data-height="{{ post.image_height }}"/>
28 </a>
31 </a>
29 </div>
32 </div>
30 {% endif %}
31 <div class="message">
32 <div class="post-info">
33 <span class="title">{{ post.title }}</span>
34 <a class="post_id" href="{% post_url post.id %}">
35 ({{ post.id }}) </a>
36 [<span class="pub_time">{{ post.pub_time }}</span>]
37 {% if not truncated %}
38 [<a href="#" onclick="javascript:addQuickReply('{{ post.id }}')
39 ; return false;">&gt;&gt;</a>]
40 {% endif %}
33 {% endif %}
41 {% if post.is_opening and need_open_link %}
34 <div class="message">
42 [<a class="link" href="
35 <div class="post-info">
43 {% url 'thread' post.id %}#form">{% trans "Reply" %}</a>]
36 <span class="title">{{ post.title }}</span>
44 {% endif %}
37 {% cache 600 post_id post.id %}
38 <a class="post_id" href="{% post_url post.id %}">
39 ({{ post.id }}) </a>
40 {% endcache %}
41 [<span class="pub_time">{{ post.pub_time }}</span>]
42 {% if thread.archived %}
43 — [{{ thread.bump_time }}]
44 {% endif %}
45 {% if not truncated and not thread.archived %}
46 [<a href="#" onclick="javascript:addQuickReply('{{ post.id }}')
47 ; return false;">&gt;&gt;</a>]
48 {% endif %}
49 {% if is_opening and need_open_link %}
50 {% if post.thread_new.archived %}
51 [<a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>]
52 {% else %}
53 [<a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>]
54 {% endif %}
55 {% endif %}
45
56
46 {% if moderator %}
57 {% if moderator %}
47 <span class="moderator_info">
58 <span class="moderator_info">
48 [<a href="{% url 'delete' post_id=post.id %}"
59 [<a href="{% url 'post_admin' post_id=post.id %}"
49 >{% trans 'Delete' %}</a>]
60 >{% trans 'Edit' %}</a>]
50 ({{ post.poster_ip }})
61 [<a href="{% url 'delete' post_id=post.id %}"
51 [<a href="{% url 'ban' post_id=post.id %}?next={{ request.path }}"
62 >{% trans 'Delete' %}</a>]
52 >{% trans 'Ban IP' %}</a>]
63 ({{ post.poster_ip }})
53 </span>
64 [<a href="{% url 'ban' post_id=post.id %}?next={{ request.path }}"
54 {% endif %}
65 >{% trans 'Ban IP' %}</a>]
55 </div>
66 </span>
56 {% autoescape off %}
67 {% endif %}
57 {% if truncated %}
68 </div>
58 {{ post.text.rendered|truncatewords_html:50 }}
69 {% autoescape off %}
59 {% else %}
70 {% if truncated %}
60 {{ post.text.rendered }}
71 {{ post.text.rendered|truncatewords_html:50 }}
72 {% else %}
73 {{ post.text.rendered }}
74 {% endif %}
75 {% endautoescape %}
76 {% cache 600 post_replies post.id post.last_edit_time moderator LANGUAGE_CODE %}
77 {% if post.is_referenced %}
78 <div class="refmap">
79 {% trans "Replies" %}:
80 {% for ref_post in post.get_sorted_referenced_posts %}
81 <a href="{% post_url ref_post.id %}">&gt;&gt;{{ ref_post.id }}</a
82 >{% if not forloop.last %},{% endif %}
83 {% endfor %}
84 </div>
85 {% endif %}
86 {% endcache %}
87 </div>
88 {% if is_opening and thread.tags.exists %}
89 <div class="metadata">
90 {% if is_opening and need_open_link %}
91 {{ thread.get_images_count }} {% trans 'images' %}.
92 {% endif %}
93 <span class="tags">
94 {% for tag in thread.get_tags %}
95 <a class="tag" href="{% url 'tag' tag.name %}">
96 #{{ tag.name }}</a>{% if not forloop.last %},{% endif %}
97 {% endfor %}
98 </span>
99 </div>
61 {% endif %}
100 {% endif %}
62 {% endautoescape %}
63 {% if post.is_referenced %}
64 <div class="refmap">
65 {% trans "Replies" %}:
66 {% for ref_post in post.get_sorted_referenced_posts %}
67 <a href="{% post_url ref_post.id %}">&gt;&gt;{{ ref_post.id }}</a
68 >{% if not forloop.last %},{% endif %}
69 {% endfor %}
70 </div>
101 </div>
71 {% endif %}
102 {% endwith %}
72 </div>
103 {% endspaceless %}
73 {% if post.is_opening and post.thread_new.tags.exists %}
104 {% endwith %}
74 <div class="metadata">
75 {% if post.is_opening and need_open_link %}
76 {{ post.thread_new.get_images_count }} {% trans 'images' %}.
77 {% endif %}
78 <span class="tags">
79 {% for tag in post.thread_new.get_tags %}
80 <a class="tag" href="{% url 'tag' tag.name %}">
81 #{{ tag.name }}</a>{% if not forloop.last %},{% endif %}
82 {% endfor %}
83 </span>
84 </div>
85 {% endif %}
86 </div>
87 {% endspaceless %}
88 {% endcache %}
105 {% endcache %}
@@ -7,9 +7,9 b''
7
7
8 {% block head %}
8 {% block head %}
9 {% if tag %}
9 {% if tag %}
10 <title>Neboard - {{ tag.name }}</title>
10 <title>{{ tag.name }} - {{ site_name }}</title>
11 {% else %}
11 {% else %}
12 <title>Neboard</title>
12 <title>{{ site_name }}</title>
13 {% endif %}
13 {% endif %}
14
14
15 {% if current_page.has_previous %}
15 {% if current_page.has_previous %}
@@ -41,10 +41,10 b''
41 <div class="tag_info">
41 <div class="tag_info">
42 <h2>
42 <h2>
43 {% if tag in user.fav_tags.all %}
43 {% if tag in user.fav_tags.all %}
44 <a href="{% url 'tag_unsubscribe' tag.name %}?next={{ request.path }}"
44 <a href="{% url 'tag' tag.name %}?method=unsubscribe&next={{ request.path }}"
45 class="fav"></a>
45 class="fav"></a>
46 {% else %}
46 {% else %}
47 <a href="{% url 'tag_subscribe' tag.name %}?next={{ request.path }}"
47 <a href="{% url 'tag' tag.name %}?method=subscribe&next={{ request.path }}"
48 class="not_fav"></a>
48 class="not_fav"></a>
49 {% endif %}
49 {% endif %}
50 #{{ tag.name }}
50 #{{ tag.name }}
@@ -66,23 +66,25 b''
66 {% endif %}
66 {% endif %}
67
67
68 {% for thread in threads %}
68 {% for thread in threads %}
69 {% cache 600 thread_short thread.thread.id thread.thread.last_edit_time moderator LANGUAGE_CODE %}
69 {% cache 600 thread_short thread.id thread.last_edit_time moderator LANGUAGE_CODE %}
70 <div class="thread">
70 <div class="thread">
71 {% post_view_truncated thread.op True moderator %}
71 {% post_view_truncated thread.get_opening_post True moderator %}
72 {% if thread.last_replies.exists %}
72 {% if not thread.archived %}
73 {% if thread.skipped_replies %}
73 {% if thread.get_last_replies.exists %}
74 {% if thread.get_skipped_replies_count %}
74 <div class="skipped_replies">
75 <div class="skipped_replies">
75 <a href="{% url 'thread' thread.op.id %}">
76 <a href="{% url 'thread' thread.get_opening_post.id %}">
76 {% blocktrans with count=thread.skipped_replies %}Skipped {{ count }} replies. Open thread to see all replies.{% endblocktrans %}
77 {% blocktrans with count=thread.get_skipped_replies_count %}Skipped {{ count }} replies. Open thread to see all replies.{% endblocktrans %}
77 </a>
78 </a>
78 </div>
79 </div>
79 {% endif %}
80 {% endif %}
80 <div class="last-replies">
81 <div class="last-replies">
81 {% for post in thread.last_replies %}
82 {% for post in thread.get_last_replies %}
82 {% post_view_truncated post moderator=moderator%}
83 {% post_view_truncated post moderator=moderator%}
83 {% endfor %}
84 {% endfor %}
84 </div>
85 </div>
85 {% endif %}
86 {% endif %}
87 {% endif %}
86 </div>
88 </div>
87 {% endcache %}
89 {% endcache %}
88 {% endfor %}
90 {% endfor %}
@@ -126,7 +128,7 b''
126 {% block metapanel %}
128 {% block metapanel %}
127
129
128 <span class="metapanel">
130 <span class="metapanel">
129 <b><a href="{% url "authors" %}">Neboard</a> 1.6 Amon</b>
131 <b><a href="{% url "authors" %}">{{ site_name }}</a> {{ version }}</b>
130 {% trans "Pages:" %}[
132 {% trans "Pages:" %}[
131 {% for page in paginator.page_range %}
133 {% for page in paginator.page_range %}
132 <a
134 <a
@@ -4,7 +4,7 b''
4 {% load humanize %}
4 {% load humanize %}
5
5
6 {% block head %}
6 {% block head %}
7 <title>Neboard settings</title>
7 <title>{% trans 'Settings' %} - {{ site_name }}</title>
8 {% endblock %}
8 {% endblock %}
9
9
10 {% block content %}
10 {% block content %}
@@ -13,10 +13,10 b''
13 {% for tag in all_tags %}
13 {% for tag in all_tags %}
14 <div class="tag_item" style="font-size: {{ tag.get_font_value }}em">
14 <div class="tag_item" style="font-size: {{ tag.get_font_value }}em">
15 {% if tag in user.fav_tags.all %}
15 {% if tag in user.fav_tags.all %}
16 <a href="{% url 'tag_unsubscribe' tag.name %}?next={{ request.path }}"
16 <a href="{% url 'tag' tag.name %}?method=unsubscribe&next={{ request.path }}"
17 class="fav"></a>
17 class="fav"></a>
18 {% else %}
18 {% else %}
19 <a href="{% url 'tag_subscribe' tag.name %}?next={{ request.path }}"
19 <a href="{% url 'tag' tag.name %}?method=subscribe&next={{ request.path }}"
20 class="not_fav"></a>
20 class="not_fav"></a>
21 {% endif %}
21 {% endif %}
22 <a class="tag" href="{% url 'tag' tag.name %}">
22 <a class="tag" href="{% url 'tag' tag.name %}">
@@ -6,38 +6,42 b''
6 {% load board %}
6 {% load board %}
7
7
8 {% block head %}
8 {% block head %}
9 <title>{{ thread.get_opening_post.get_title|striptags|truncatewords:10 }} - Neboard</title>
9 <title>{{ opening_post.get_title|striptags|truncatewords:10 }}
10 - {{ site_name }}</title>
10 {% endblock %}
11 {% endblock %}
11
12
12 {% block content %}
13 {% block content %}
13 {% spaceless %}
14 {% spaceless %}
14 {% get_current_language as LANGUAGE_CODE %}
15 {% get_current_language as LANGUAGE_CODE %}
15
16
16 <div class="image-mode-tab">
17 {% cache 600 thread_view thread.id thread.last_edit_time moderator LANGUAGE_CODE %}
17 <a class="current_mode" href="{% url 'thread' thread.get_opening_post.id %}">{% trans 'Normal mode' %}</a>,
18
18 <a href="{% url 'thread_mode' thread.get_opening_post.id 'gallery' %}">{% trans 'Gallery mode' %}</a>
19 <div class="image-mode-tab">
19 </div>
20 <a class="current_mode" href="{% url 'thread' opening_post.id %}">{% trans 'Normal mode' %}</a>,
21 <a href="{% url 'thread_mode' opening_post.id 'gallery' %}">{% trans 'Gallery mode' %}</a>
22 </div>
20
23
21 {% if bumpable %}
24 {% if bumpable %}
22 <div class="bar-bg">
25 <div class="bar-bg">
23 <div class="bar-value" style="width:{{ bumplimit_progress }}%" id="bumplimit_progress">
26 <div class="bar-value" style="width:{{ bumplimit_progress }}%" id="bumplimit_progress">
24 </div>
27 </div>
25 <div class="bar-text">
28 <div class="bar-text">
26 <span id="left_to_limit">{{ posts_left }}</span> {% trans 'posts to bumplimit' %}
29 <span id="left_to_limit">{{ posts_left }}</span> {% trans 'posts to bumplimit' %}
27 </div>
28 </div>
30 </div>
29 {% endif %}
31 </div>
32 {% endif %}
33
30 <div class="thread">
34 <div class="thread">
31 {% for post in posts %}
35 {% for post in thread.get_replies %}
32 {% post_view post moderator=moderator %}
36 {% post_view post moderator=moderator %}
33 {% endfor %}
37 {% endfor %}
34 </div>
38 </div>
35
39
36 {% if not thread.archived %}
40 {% if not thread.archived %}
37
41
38 <div class="post-form-w">
42 <div class="post-form-w">
39 <script src="{% static 'js/panel.js' %}"></script>
43 <script src="{% static 'js/panel.js' %}"></script>
40 <div class="form-title">{% trans "Reply to thread" %} #{{ thread.get_opening_post.id }}</div>
44 <div class="form-title">{% trans "Reply to thread" %} #{{ opening_post.id }}</div>
41 <div class="post-form">
45 <div class="post-form">
42 <form id="form" enctype="multipart/form-data" method="post"
46 <form id="form" enctype="multipart/form-data" method="post"
43 >{% csrf_token %}
47 >{% csrf_token %}
@@ -57,6 +61,8 b''
57
61
58 <script src="{% static 'js/thread.js' %}"></script>
62 <script src="{% static 'js/thread.js' %}"></script>
59
63
64 {% endcache %}
65
60 {% endspaceless %}
66 {% endspaceless %}
61 {% endblock %}
67 {% endblock %}
62
68
@@ -6,7 +6,8 b''
6 {% load board %}
6 {% load board %}
7
7
8 {% block head %}
8 {% block head %}
9 <title>{{ thread.get_opening_post.get_title|striptags|truncatewords:10 }} - Neboard</title>
9 <title>{{ thread.get_opening_post.get_title|striptags|truncatewords:10 }}
10 - {{ site_name }}</title>
10 {% endblock %}
11 {% endblock %}
11
12
12 {% block content %}
13 {% block content %}
@@ -25,11 +25,11 b' def post_url(*args, **kwargs):'
25 post = get_object_or_404(Post, id=post_id)
25 post = get_object_or_404(Post, id=post_id)
26
26
27 if not post.is_opening():
27 if not post.is_opening():
28 link = reverse(thread, kwargs={
28 link = reverse('thread', kwargs={
29 'post_id': post.thread_new.get_opening_post().id}) + '#' + str(
29 'post_id': post.thread_new.get_opening_post().id}) + '#' + str(
30 post_id)
30 post_id)
31 else:
31 else:
32 link = reverse(thread, kwargs={'post_id': post_id})
32 link = reverse('thread', kwargs={'post_id': post_id})
33
33
34 return link
34 return link
35
35
@@ -73,4 +73,4 b' def post_view_truncated(post, need_open_'
73 'truncated': True,
73 'truncated': True,
74 'need_open_link': need_open_link,
74 'need_open_link': need_open_link,
75 'moderator': moderator,
75 'moderator': moderator,
76 } No newline at end of file
76 }
@@ -1,9 +1,13 b''
1 # coding=utf-8
1 # coding=utf-8
2 import time
3 import logging
4
2 from django.test import TestCase
5 from django.test import TestCase
3 from django.test.client import Client
6 from django.test.client import Client
4 import time
7 from django.core.urlresolvers import reverse, NoReverseMatch
5
8
6 from boards.models import Post, Tag
9 from boards.models import Post, Tag
10 from boards import urls
7 from neboard import settings
11 from neboard import settings
8
12
9 PAGE_404 = 'boards/404.html'
13 PAGE_404 = 'boards/404.html'
@@ -18,6 +22,8 b' HTTP_CODE_REDIRECT = 302'
18 HTTP_CODE_OK = 200
22 HTTP_CODE_OK = 200
19 HTTP_CODE_NOT_FOUND = 404
23 HTTP_CODE_NOT_FOUND = 404
20
24
25 logger = logging.getLogger(__name__)
26
21
27
22 class PostTests(TestCase):
28 class PostTests(TestCase):
23
29
@@ -223,11 +229,29 b' class FormTest(TestCase):'
223
229
224
230
225 class ViewTest(TestCase):
231 class ViewTest(TestCase):
226 def test_index(self):
232
233 def test_all_views(self):
234 '''
235 Try opening all views defined in ulrs.py that don't need additional
236 parameters
237 '''
238
227 client = Client()
239 client = Client()
240 for url in urls.urlpatterns:
241 try:
242 view_name = url.name
243 logger.debug('Testing view %s' % view_name)
228
244
229 response = client.get('/')
245 try:
230 self.assertEqual(HTTP_CODE_OK, response.status_code, 'Index page not '
246 response = client.get(reverse(view_name))
231 'opened')
247
232 self.assertEqual('boards/posting_general.html', response.templates[0]
248 self.assertEqual(HTTP_CODE_OK, response.status_code,
233 .name, 'Index page should open posting_general template')
249 '%s view not opened' % view_name)
250 except NoReverseMatch:
251 # This view just needs additional arguments
252 pass
253 except Exception, e:
254 self.fail('Got exception %s at %s view' % (e, view_name))
255 except AttributeError:
256 # This is normal, some views do not have names
257 pass
@@ -1,7 +1,13 b''
1 from django.conf.urls import patterns, url, include
1 from django.conf.urls import patterns, url, include
2 from boards import views
2 from boards import views
3 from boards.rss import AllThreadsFeed, TagThreadsFeed, ThreadPostsFeed
3 from boards.rss import AllThreadsFeed, TagThreadsFeed, ThreadPostsFeed
4 from boards.views import api
4 from boards.views import api, tag_threads, all_threads, archived_threads, \
5 login, settings, all_tags
6 from boards.views.authors import AuthorsView
7 from boards.views.delete_post import DeletePostView
8 from boards.views.ban import BanUserView
9 from boards.views.static import StaticPageView
10 from boards.views.post_admin import PostAdminView
5
11
6 js_info_dict = {
12 js_info_dict = {
7 'packages': ('boards',),
13 'packages': ('boards',),
@@ -10,41 +16,46 b' js_info_dict = {'
10 urlpatterns = patterns('',
16 urlpatterns = patterns('',
11
17
12 # /boards/
18 # /boards/
13 url(r'^$', views.index, name='index'),
19 url(r'^$', all_threads.AllThreadsView.as_view(), name='index'),
14 # /boards/page/
20 # /boards/page/
15 url(r'^page/(?P<page>\w+)/$', views.index, name='index'),
21 url(r'^page/(?P<page>\w+)/$', all_threads.AllThreadsView.as_view(),
22 name='index'),
16
23
17 url(r'^archive/$', views.archive, name='archive'),
24 url(r'^archive/$', archived_threads.ArchiveView.as_view(), name='archive'),
18 url(r'^archive/page/(?P<page>\w+)/$', views.archive, name='archive'),
25 url(r'^archive/page/(?P<page>\w+)/$',
26 archived_threads.ArchiveView.as_view(), name='archive'),
19
27
20 # login page
28 # login page
21 url(r'^login/$', views.login, name='login'),
29 url(r'^login/$', login.LoginView.as_view(), name='login'),
22
30
23 # /boards/tag/tag_name/
31 # /boards/tag/tag_name/
24 url(r'^tag/(?P<tag_name>\w+)/$', views.tag, name='tag'),
32 url(r'^tag/(?P<tag_name>\w+)/$', tag_threads.TagView.as_view(),
33 name='tag'),
25 # /boards/tag/tag_id/page/
34 # /boards/tag/tag_id/page/
26 url(r'^tag/(?P<tag_name>\w+)/page/(?P<page>\w+)/$', views.tag, name='tag'),
35 url(r'^tag/(?P<tag_name>\w+)/page/(?P<page>\w+)/$',
27
36 tag_threads.TagView.as_view(), name='tag'),
28 # /boards/tag/tag_name/unsubscribe/
29 url(r'^tag/(?P<tag_name>\w+)/subscribe/$', views.tag_subscribe,
30 name='tag_subscribe'),
31 # /boards/tag/tag_name/unsubscribe/
32 url(r'^tag/(?P<tag_name>\w+)/unsubscribe/$', views.tag_unsubscribe,
33 name='tag_unsubscribe'),
34
37
35 # /boards/thread/
38 # /boards/thread/
36 url(r'^thread/(?P<post_id>\w+)/$', views.thread, name='thread'),
39 url(r'^thread/(?P<post_id>\w+)/$', views.thread.ThreadView.as_view(),
37 url(r'^thread/(?P<post_id>\w+)/(?P<mode>\w+)/$', views.thread, name='thread_mode'),
40 name='thread'),
38 url(r'^settings/$', views.settings, name='settings'),
41 url(r'^thread/(?P<post_id>\w+)/(?P<mode>\w+)/$', views.thread.ThreadView
39 url(r'^tags/$', views.all_tags, name='tags'),
42 .as_view(), name='thread_mode'),
43
44 # /boards/post_admin/
45 url(r'^post_admin/(?P<post_id>\w+)/$', PostAdminView.as_view(),
46 name='post_admin'),
47
48 url(r'^settings/$', settings.SettingsView.as_view(), name='settings'),
49 url(r'^tags/$', all_tags.AllTagsView.as_view(), name='tags'),
40 url(r'^captcha/', include('captcha.urls')),
50 url(r'^captcha/', include('captcha.urls')),
41 url(r'^jump/(?P<post_id>\w+)/$', views.jump_to_post, name='jumper'),
51 url(r'^authors/$', AuthorsView.as_view(), name='authors'),
42 url(r'^authors/$', views.authors, name='authors'),
52 url(r'^delete/(?P<post_id>\w+)/$', DeletePostView.as_view(),
43 url(r'^delete/(?P<post_id>\w+)/$', views.delete, name='delete'),
53 name='delete'),
44 url(r'^ban/(?P<post_id>\w+)/$', views.ban, name='ban'),
54 url(r'^ban/(?P<post_id>\w+)/$', BanUserView.as_view(), name='ban'),
45
55
46 url(r'^banned/$', views.you_are_banned, name='banned'),
56 url(r'^banned/$', views.banned.BannedView.as_view(), name='banned'),
47 url(r'^staticpage/(?P<name>\w+)/$', views.static_page, name='staticpage'),
57 url(r'^staticpage/(?P<name>\w+)/$', StaticPageView.as_view(),
58 name='staticpage'),
48
59
49 # RSS feeds
60 # RSS feeds
50 url(r'^rss/$', AllThreadsFeed()),
61 url(r'^rss/$', AllThreadsFeed()),
@@ -54,7 +65,8 b" urlpatterns = patterns('',"
54 url(r'^thread/(?P<post_id>\w+)/rss/$', ThreadPostsFeed()),
65 url(r'^thread/(?P<post_id>\w+)/rss/$', ThreadPostsFeed()),
55
66
56 # i18n
67 # i18n
57 url(r'^jsi18n/$', 'boards.views.cached_js_catalog', js_info_dict, name='js_info_dict'),
68 url(r'^jsi18n/$', 'boards.views.cached_js_catalog', js_info_dict,
69 name='js_info_dict'),
58
70
59 # API
71 # API
60 url(r'^api/post/(?P<post_id>\w+)/$', api.get_post, name="get_post"),
72 url(r'^api/post/(?P<post_id>\w+)/$', api.get_post, name="get_post"),
@@ -62,9 +74,10 b" urlpatterns = patterns('',"
62 api.api_get_threaddiff, name="get_thread_diff"),
74 api.api_get_threaddiff, name="get_thread_diff"),
63 url(r'^api/threads/(?P<count>\w+)/$', api.api_get_threads,
75 url(r'^api/threads/(?P<count>\w+)/$', api.api_get_threads,
64 name='get_threads'),
76 name='get_threads'),
65 url(r'api/tags/$', api.api_get_tags, name='get_tags'),
77 url(r'^api/tags/$', api.api_get_tags, name='get_tags'),
66 url(r'api/thread/(?P<opening_post_id>\w+)/$', api.api_get_thread_posts,
78 url(r'^api/thread/(?P<opening_post_id>\w+)/$', api.api_get_thread_posts,
67 name='get_thread'),
79 name='get_thread'),
68 url(r'api/add_post/(?P<opening_post_id>\w+)/$', api.api_add_post, name='add_post'),
80 url(r'^api/add_post/(?P<opening_post_id>\w+)/$', api.api_add_post,
81 name='add_post'),
69
82
70 )
83 )
@@ -1,6 +1,7 b''
1 """
1 """
2 This module contains helper functions and helper classes.
2 This module contains helper functions and helper classes.
3 """
3 """
4 from django.utils import timezone
4
5
5 from neboard import settings
6 from neboard import settings
6 import time
7 import time
@@ -70,4 +71,10 b' def get_client_ip(request):'
70 ip = x_forwarded_for.split(',')[-1].strip()
71 ip = x_forwarded_for.split(',')[-1].strip()
71 else:
72 else:
72 ip = request.META.get('REMOTE_ADDR')
73 ip = request.META.get('REMOTE_ADDR')
73 return ip No newline at end of file
74 return ip
75
76
77 def datetime_to_epoch(datetime):
78 return int(time.mktime(timezone.localtime(
79 datetime,timezone.get_current_timezone()).timetuple())
80 * 1000000 + datetime.microsecond) No newline at end of file
This diff has been collapsed as it changes many lines, (600 lines changed) Show them Hide them
@@ -1,609 +1,9 b''
1 from datetime import datetime, timedelta
2
3 from django.db.models import Count
4
5
6 OLD_USER_AGE_DAYS = 90
7
8 __author__ = 'neko259'
1 __author__ = 'neko259'
9
2
10 import hashlib
11 import string
12 import time
13 import re
14
15 from django.core import serializers
16 from django.core.urlresolvers import reverse
17 from django.http import HttpResponseRedirect, Http404
18 from django.http.response import HttpResponse
19 from django.template import RequestContext
20 from django.shortcuts import render, redirect, get_object_or_404
21 from django.utils import timezone
22 from django.db import transaction
23 from django.views.decorators.cache import cache_page
3 from django.views.decorators.cache import cache_page
24 from django.views.i18n import javascript_catalog
4 from django.views.i18n import javascript_catalog
25 from django.core.paginator import Paginator
26
27 from boards import forms
28 import boards
29 from boards import utils
30 from boards.forms import ThreadForm, PostForm, SettingsForm, PlainErrorList, \
31 ThreadCaptchaForm, PostCaptchaForm, LoginForm, ModeratorSettingsForm
32 from boards.models import Post, Tag, Ban, User, Thread
33 from boards.models.post import SETTING_MODERATE, REGEX_REPLY
34 from boards.models.user import RANK_USER
35 from boards import authors
36 from boards.utils import get_client_ip
37 import neboard
38
39
40 BAN_REASON_SPAM = 'Autoban: spam bot'
41 MODE_GALLERY = 'gallery'
42 MODE_NORMAL = 'normal'
43
44 DEFAULT_PAGE = 1
45
46
47 def index(request, page=DEFAULT_PAGE):
48 context = _init_default_context(request)
49
50 if utils.need_include_captcha(request):
51 threadFormClass = ThreadCaptchaForm
52 kwargs = {'request': request}
53 else:
54 threadFormClass = ThreadForm
55 kwargs = {}
56
57 if request.method == 'POST':
58 form = threadFormClass(request.POST, request.FILES,
59 error_class=PlainErrorList, **kwargs)
60 form.session = request.session
61
62 if form.is_valid():
63 return _new_post(request, form)
64 if form.need_to_ban:
65 # Ban user because he is suspected to be a bot
66 _ban_current_user(request)
67 else:
68 form = threadFormClass(error_class=PlainErrorList, **kwargs)
69
70 threads = []
71 for thread_to_show in Post.objects.get_threads(page=int(page)):
72 threads.append(_get_template_thread(thread_to_show))
73
74 # TODO Make this generic for tag and threads list pages
75 context['threads'] = None if len(threads) == 0 else threads
76 context['form'] = form
77
78 paginator = Paginator(Thread.objects.filter(archived=False),
79 neboard.settings.THREADS_PER_PAGE)
80 _get_page_context(paginator, context, page)
81
82 return render(request, 'boards/posting_general.html',
83 context)
84
85
86 def archive(request, page=DEFAULT_PAGE):
87 """
88 Get archived posts
89 """
90
91 context = _init_default_context(request)
92
93 threads = []
94 for thread_to_show in Post.objects.get_threads(page=int(page),
95 archived=True):
96 threads.append(_get_template_thread(thread_to_show))
97
98 context['threads'] = threads
99
100 paginator = Paginator(Thread.objects.filter(archived=True),
101 neboard.settings.THREADS_PER_PAGE)
102 _get_page_context(paginator, context, page)
103
104 return render(request, 'boards/archive.html', context)
105
106
107 @transaction.atomic
108 def _new_post(request, form, opening_post=None, html_response=True):
109 """Add a new post (in thread or as a reply)."""
110
111 ip = get_client_ip(request)
112 is_banned = Ban.objects.filter(ip=ip).exists()
113
114 if is_banned:
115 if html_response:
116 return redirect(you_are_banned)
117 else:
118 return
119
120 data = form.cleaned_data
121
122 title = data['title']
123 text = data['text']
124
125 text = _remove_invalid_links(text)
126
127 if 'image' in data.keys():
128 image = data['image']
129 else:
130 image = None
131
132 tags = []
133
134 if not opening_post:
135 tag_strings = data['tags']
136
137 if tag_strings:
138 tag_strings = tag_strings.split(' ')
139 for tag_name in tag_strings:
140 tag_name = string.lower(tag_name.strip())
141 if len(tag_name) > 0:
142 tag, created = Tag.objects.get_or_create(name=tag_name)
143 tags.append(tag)
144 post_thread = None
145 else:
146 post_thread = opening_post.thread_new
147
148 post = Post.objects.create_post(title=title, text=text, ip=ip,
149 thread=post_thread, image=image,
150 tags=tags, user=_get_user(request))
151
152 thread_to_show = (opening_post.id if opening_post else post.id)
153
154 if html_response:
155 if opening_post:
156 return redirect(reverse(thread, kwargs={'post_id': thread_to_show}) +
157 '#' + str(post.id))
158 else:
159 return redirect(thread, post_id=thread_to_show)
160
161
162 def tag(request, tag_name, page=DEFAULT_PAGE):
163 """
164 Get all tag threads. Threads are split in pages, so some page is
165 requested.
166 """
167
168 tag = get_object_or_404(Tag, name=tag_name)
169 threads = []
170 for thread_to_show in Post.objects.get_threads(page=int(page), tag=tag):
171 threads.append(_get_template_thread(thread_to_show))
172
173 if request.method == 'POST':
174 form = ThreadForm(request.POST, request.FILES,
175 error_class=PlainErrorList)
176 form.session = request.session
177
178 if form.is_valid():
179 return _new_post(request, form)
180 if form.need_to_ban:
181 # Ban user because he is suspected to be a bot
182 _ban_current_user(request)
183 else:
184 form = forms.ThreadForm(initial={'tags': tag_name},
185 error_class=PlainErrorList)
186
187 context = _init_default_context(request)
188 context['threads'] = None if len(threads) == 0 else threads
189 context['tag'] = tag
190
191 paginator = Paginator(Post.objects.get_threads(tag=tag),
192 neboard.settings.THREADS_PER_PAGE)
193 _get_page_context(paginator, context, page)
194
195 context['form'] = form
196
197 return render(request, 'boards/posting_general.html',
198 context)
199
200
201 def thread(request, post_id, mode=MODE_NORMAL):
202 """Get all thread posts"""
203
204 if utils.need_include_captcha(request):
205 postFormClass = PostCaptchaForm
206 kwargs = {'request': request}
207 else:
208 postFormClass = PostForm
209 kwargs = {}
210
211 opening_post = get_object_or_404(Post, id=post_id)
212
213 # If this is not OP, don't show it as it is
214 if not opening_post.is_opening():
215 raise Http404
216
217 if request.method == 'POST' and not opening_post.thread_new.archived:
218 form = postFormClass(request.POST, request.FILES,
219 error_class=PlainErrorList, **kwargs)
220 form.session = request.session
221
222 if form.is_valid():
223 return _new_post(request, form, opening_post)
224 if form.need_to_ban:
225 # Ban user because he is suspected to be a bot
226 _ban_current_user(request)
227 else:
228 form = postFormClass(error_class=PlainErrorList, **kwargs)
229
230 thread_to_show = opening_post.thread_new
231
232 context = _init_default_context(request)
233
234 posts = thread_to_show.get_replies()
235 context['form'] = form
236 context["last_update"] = _datetime_to_epoch(thread_to_show.last_edit_time)
237 context["thread"] = thread_to_show
238
239 if MODE_NORMAL == mode:
240 context['bumpable'] = thread_to_show.can_bump()
241 if context['bumpable']:
242 context['posts_left'] = neboard.settings.MAX_POSTS_PER_THREAD - posts \
243 .count()
244 context['bumplimit_progress'] = str(
245 float(context['posts_left']) /
246 neboard.settings.MAX_POSTS_PER_THREAD * 100)
247
248 context['posts'] = posts
249
250 document = 'boards/thread.html'
251 elif MODE_GALLERY == mode:
252 context['posts'] = posts.filter(image_width__gt=0)
253
254 document = 'boards/thread_gallery.html'
255 else:
256 raise Http404
257
258 return render(request, document, context)
259
260
261 def login(request):
262 """Log in with user id"""
263
264 context = _init_default_context(request)
265
266 if request.method == 'POST':
267 form = LoginForm(request.POST, request.FILES,
268 error_class=PlainErrorList)
269 form.session = request.session
270
271 if form.is_valid():
272 user = User.objects.get(user_id=form.cleaned_data['user_id'])
273 request.session['user_id'] = user.id
274 return redirect(index)
275
276 else:
277 form = LoginForm()
278
279 context['form'] = form
280
281 return render(request, 'boards/login.html', context)
282
283
284 def settings(request):
285 """User's settings"""
286
287 context = _init_default_context(request)
288 user = _get_user(request)
289 is_moderator = user.is_moderator()
290
291 if request.method == 'POST':
292 with transaction.atomic():
293 if is_moderator:
294 form = ModeratorSettingsForm(request.POST,
295 error_class=PlainErrorList)
296 else:
297 form = SettingsForm(request.POST, error_class=PlainErrorList)
298
299 if form.is_valid():
300 selected_theme = form.cleaned_data['theme']
301
302 user.save_setting('theme', selected_theme)
303
304 if is_moderator:
305 moderate = form.cleaned_data['moderate']
306 user.save_setting(SETTING_MODERATE, moderate)
307
308 return redirect(settings)
309 else:
310 selected_theme = _get_theme(request)
311
312 if is_moderator:
313 form = ModeratorSettingsForm(initial={'theme': selected_theme,
314 'moderate': context['moderator']},
315 error_class=PlainErrorList)
316 else:
317 form = SettingsForm(initial={'theme': selected_theme},
318 error_class=PlainErrorList)
319
320 context['form'] = form
321
322 return render(request, 'boards/settings.html', context)
323
324
325 def all_tags(request):
326 """All tags list"""
327
328 context = _init_default_context(request)
329 context['all_tags'] = Tag.objects.get_not_empty_tags()
330
331 return render(request, 'boards/tags.html', context)
332
333
334 def jump_to_post(request, post_id):
335 """Determine thread in which the requested post is and open it's page"""
336
337 post = get_object_or_404(Post, id=post_id)
338
339 if not post.thread:
340 return redirect(thread, post_id=post.id)
341 else:
342 return redirect(reverse(thread, kwargs={'post_id': post.thread.id})
343 + '#' + str(post.id))
344
345
346 def authors(request):
347 """Show authors list"""
348
349 context = _init_default_context(request)
350 context['authors'] = boards.authors.authors
351
352 return render(request, 'boards/authors.html', context)
353
354
355 @transaction.atomic
356 def delete(request, post_id):
357 """Delete post"""
358
359 user = _get_user(request)
360 post = get_object_or_404(Post, id=post_id)
361
362 if user.is_moderator():
363 # TODO Show confirmation page before deletion
364 Post.objects.delete_post(post)
365
366 if not post.thread:
367 return _redirect_to_next(request)
368 else:
369 return redirect(thread, post_id=post.thread.id)
370
371
372 @transaction.atomic
373 def ban(request, post_id):
374 """Ban user"""
375
376 user = _get_user(request)
377 post = get_object_or_404(Post, id=post_id)
378
379 if user.is_moderator():
380 # TODO Show confirmation page before ban
381 ban, created = Ban.objects.get_or_create(ip=post.poster_ip)
382 if created:
383 ban.reason = 'Banned for post ' + str(post_id)
384 ban.save()
385
386 return _redirect_to_next(request)
387
388
389 def you_are_banned(request):
390 """Show the page that notifies that user is banned"""
391
392 context = _init_default_context(request)
393
394 ban = get_object_or_404(Ban, ip=utils.get_client_ip(request))
395 context['ban_reason'] = ban.reason
396 return render(request, 'boards/staticpages/banned.html', context)
397
398
399 def page_404(request):
400 """Show page 404 (not found error)"""
401
402 context = _init_default_context(request)
403 return render(request, 'boards/404.html', context)
404
405
406 @transaction.atomic
407 def tag_subscribe(request, tag_name):
408 """Add tag to favorites"""
409
410 user = _get_user(request)
411 tag = get_object_or_404(Tag, name=tag_name)
412
413 if not tag in user.fav_tags.all():
414 user.add_tag(tag)
415
416 return _redirect_to_next(request)
417
418
419 @transaction.atomic
420 def tag_unsubscribe(request, tag_name):
421 """Remove tag from favorites"""
422
423 user = _get_user(request)
424 tag = get_object_or_404(Tag, name=tag_name)
425
426 if tag in user.fav_tags.all():
427 user.remove_tag(tag)
428
429 return _redirect_to_next(request)
430
431
432 def static_page(request, name):
433 """Show a static page that needs only tags list and a CSS"""
434
435 context = _init_default_context(request)
436 return render(request, 'boards/staticpages/' + name + '.html', context)
437
438
439 def api_get_post(request, post_id):
440 """
441 Get the JSON of a post. This can be
442 used as and API for external clients.
443 """
444
445 post = get_object_or_404(Post, id=post_id)
446
447 json = serializers.serialize("json", [post], fields=(
448 "pub_time", "_text_rendered", "title", "text", "image",
449 "image_width", "image_height", "replies", "tags"
450 ))
451
452 return HttpResponse(content=json)
453
5
454
6
455 @cache_page(86400)
7 @cache_page(86400)
456 def cached_js_catalog(request, domain='djangojs', packages=None):
8 def cached_js_catalog(request, domain='djangojs', packages=None):
457 return javascript_catalog(request, domain, packages)
9 return javascript_catalog(request, domain, packages)
458
459
460 def _get_theme(request, user=None):
461 """Get user's CSS theme"""
462
463 if not user:
464 user = _get_user(request)
465 theme = user.get_setting('theme')
466 if not theme:
467 theme = neboard.settings.DEFAULT_THEME
468
469 return theme
470
471
472 def _init_default_context(request):
473 """Create context with default values that are used in most views"""
474
475 context = RequestContext(request)
476
477 user = _get_user(request)
478 context['user'] = user
479 context['tags'] = user.get_sorted_fav_tags()
480 context['posts_per_day'] = float(Post.objects.get_posts_per_day())
481
482 theme = _get_theme(request, user)
483 context['theme'] = theme
484 context['theme_css'] = 'css/' + theme + '/base_page.css'
485
486 # This shows the moderator panel
487 moderate = user.get_setting(SETTING_MODERATE)
488 if moderate == 'True':
489 context['moderator'] = user.is_moderator()
490 else:
491 context['moderator'] = False
492
493 return context
494
495
496 def _get_user(request):
497 """
498 Get current user from the session. If the user does not exist, create
499 a new one.
500 """
501
502 session = request.session
503 if not 'user_id' in session:
504 request.session.save()
505
506 md5 = hashlib.md5()
507 md5.update(session.session_key)
508 new_id = md5.hexdigest()
509
510 while User.objects.filter(user_id=new_id).exists():
511 md5.update(str(timezone.now()))
512 new_id = md5.hexdigest()
513
514 time_now = timezone.now()
515 user = User.objects.create(user_id=new_id, rank=RANK_USER,
516 registration_time=time_now)
517
518 _delete_old_users()
519
520 session['user_id'] = user.id
521 else:
522 user = User.objects.get(id=session['user_id'])
523
524 return user
525
526
527 def _redirect_to_next(request):
528 """
529 If a 'next' parameter was specified, redirect to the next page. This is
530 used when the user is required to return to some page after the current
531 view has finished its work.
532 """
533
534 if 'next' in request.GET:
535 next_page = request.GET['next']
536 return HttpResponseRedirect(next_page)
537 else:
538 return redirect(index)
539
540
541 @transaction.atomic
542 def _ban_current_user(request):
543 """Add current user to the IP ban list"""
544
545 ip = utils.get_client_ip(request)
546 ban, created = Ban.objects.get_or_create(ip=ip)
547 if created:
548 ban.can_read = False
549 ban.reason = BAN_REASON_SPAM
550 ban.save()
551
552
553 def _remove_invalid_links(text):
554 """
555 Replace invalid links in posts so that they won't be parsed.
556 Invalid links are links to non-existent posts
557 """
558
559 for reply_number in re.finditer(REGEX_REPLY, text):
560 post_id = reply_number.group(1)
561 post = Post.objects.filter(id=post_id)
562 if not post.exists():
563 text = string.replace(text, '>>' + post_id, post_id)
564
565 return text
566
567
568 def _datetime_to_epoch(datetime):
569 return int(time.mktime(timezone.localtime(
570 datetime,timezone.get_current_timezone()).timetuple())
571 * 1000000 + datetime.microsecond)
572
573
574 def _get_template_thread(thread_to_show):
575 """Get template values for thread"""
576
577 last_replies = thread_to_show.get_last_replies()
578 skipped_replies_count = thread_to_show.get_replies().count() \
579 - len(last_replies) - 1
580 return {
581 'thread': thread_to_show,
582 'op': thread_to_show.get_replies()[0],
583 'bumpable': thread_to_show.can_bump(),
584 'last_replies': last_replies,
585 'skipped_replies': skipped_replies_count,
586 }
587
588
589 def _delete_old_users():
590 """
591 Delete users with no favorite tags and posted messages. These can be spam
592 bots or just old user accounts
593 """
594
595 old_registration_date = datetime.now().date() - timedelta(OLD_USER_AGE_DAYS)
596
597 for user in User.objects.annotate(tags_count=Count('fav_tags')).filter(
598 tags_count=0).filter(registration_time__lt=old_registration_date):
599 if not Post.objects.filter(user=user).exists():
600 user.delete()
601
602
603 def _get_page_context(paginator, context, page):
604 """
605 Get pagination context variables
606 """
607
608 context['paginator'] = paginator
609 context['current_page'] = paginator.page(int(page))
@@ -5,10 +5,12 b' from django.http import HttpResponse'
5 from django.shortcuts import get_object_or_404, render
5 from django.shortcuts import get_object_or_404, render
6 from django.template import RequestContext
6 from django.template import RequestContext
7 from django.utils import timezone
7 from django.utils import timezone
8 from django.core import serializers
9
8 from boards.forms import PostForm, PlainErrorList
10 from boards.forms import PostForm, PlainErrorList
9 from boards.models import Post, Thread, Tag
11 from boards.models import Post, Thread, Tag
10 from boards.views import _datetime_to_epoch, _new_post, \
12 from boards.utils import datetime_to_epoch
11 _ban_current_user
13 from boards.views.thread import ThreadView
12
14
13 __author__ = 'neko259'
15 __author__ = 'neko259'
14
16
@@ -53,7 +55,7 b' def api_get_threaddiff(request, thread_i'
53 json_data['added'].append(_get_post_data(post.id, diff_type, request))
55 json_data['added'].append(_get_post_data(post.id, diff_type, request))
54 for post in updated_posts:
56 for post in updated_posts:
55 json_data['updated'].append(_get_post_data(post.id, diff_type, request))
57 json_data['updated'].append(_get_post_data(post.id, diff_type, request))
56 json_data['last_update'] = _datetime_to_epoch(thread.last_edit_time)
58 json_data['last_update'] = datetime_to_epoch(thread.last_edit_time)
57
59
58 return HttpResponse(content=json.dumps(json_data))
60 return HttpResponse(content=json.dumps(json_data))
59
61
@@ -78,7 +80,8 b' def api_add_post(request, opening_post_i'
78 # _ban_current_user(request)
80 # _ban_current_user(request)
79 # status = STATUS_ERROR
81 # status = STATUS_ERROR
80 if form.is_valid():
82 if form.is_valid():
81 _new_post(request, form, opening_post, html_response=False)
83 ThreadView().new_post(request, form, opening_post,
84 html_response=False)
82 else:
85 else:
83 status = STATUS_ERROR
86 status = STATUS_ERROR
84 errors = form.as_json_errors()
87 errors = form.as_json_errors()
@@ -178,12 +181,28 b' def api_get_thread_posts(request, openin'
178
181
179 for post in posts:
182 for post in posts:
180 json_post_list.append(_get_post_data(post.id))
183 json_post_list.append(_get_post_data(post.id))
181 json_data['last_update'] = _datetime_to_epoch(thread.last_edit_time)
184 json_data['last_update'] = datetime_to_epoch(thread.last_edit_time)
182 json_data['posts'] = json_post_list
185 json_data['posts'] = json_post_list
183
186
184 return HttpResponse(content=json.dumps(json_data))
187 return HttpResponse(content=json.dumps(json_data))
185
188
186
189
190 def api_get_post(request, post_id):
191 """
192 Get the JSON of a post. This can be
193 used as and API for external clients.
194 """
195
196 post = get_object_or_404(Post, id=post_id)
197
198 json = serializers.serialize("json", [post], fields=(
199 "pub_time", "_text_rendered", "title", "text", "image",
200 "image_width", "image_height", "replies", "tags"
201 ))
202
203 return HttpResponse(content=json)
204
205
187 # TODO Add pub time and replies
206 # TODO Add pub time and replies
188 def _get_post_data(post_id, format_type=DIFF_TYPE_JSON, request=None,
207 def _get_post_data(post_id, format_type=DIFF_TYPE_JSON, request=None,
189 include_last_update=False):
208 include_last_update=False):
@@ -200,6 +219,6 b' def _get_post_data(post_id, format_type='
200 post_json['image'] = post.image.url
219 post_json['image'] = post.image.url
201 post_json['image_preview'] = post.image.url_200x150
220 post_json['image_preview'] = post.image.url_200x150
202 if include_last_update:
221 if include_last_update:
203 post_json['bump_time'] = _datetime_to_epoch(
222 post_json['bump_time'] = datetime_to_epoch(
204 post.thread_new.bump_time)
223 post.thread_new.bump_time)
205 return post_json
224 return post_json
@@ -14,3 +14,15 b' thread.'
14 * Added API for viewing threads and posts
14 * Added API for viewing threads and posts
15 * New tag popularity algorithm
15 * New tag popularity algorithm
16 * Tags list page changes. Now tags list is more like a tag cloud
16 * Tags list page changes. Now tags list is more like a tag cloud
17
18 # 1.7 Anubis
19 * [ADMIN] Added admin page for post editing, capable of adding and removing tags
20 * [CODE] Post view unification
21 * Post caching instead of thread caching
22 * Simplified tag list page
23 * [API] Added api for thread update in json
24 * Image duplicate check
25 * Posting over ajax (no page reload now)
26 * Update last update time with thread update
27 * Added z-index to the images to move the dragged image to front
28 * [CODE] Major view refactoring. Now almost all views are class-based
@@ -223,6 +223,8 b' POSTING_DELAY = 20 # seconds'
223
223
224 COMPRESS_HTML = True
224 COMPRESS_HTML = True
225
225
226 VERSION = '1.7 Anubis'
227
226 # Debug mode middlewares
228 # Debug mode middlewares
227 if DEBUG:
229 if DEBUG:
228 MIDDLEWARE_CLASSES += (
230 MIDDLEWARE_CLASSES += (
@@ -5,6 +5,8 b' from django.conf.urls.static import stat'
5 from django.contrib import admin
5 from django.contrib import admin
6 from neboard import settings
6 from neboard import settings
7
7
8 from boards.views.not_found import NotFoundView
9
8 admin.autodiscover()
10 admin.autodiscover()
9
11
10 urlpatterns = patterns('',
12 urlpatterns = patterns('',
@@ -20,4 +22,4 b" urlpatterns = patterns('',"
20 url(r'^', include('boards.urls')),
22 url(r'^', include('boards.urls')),
21 ) + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
23 ) + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
22
24
23 handler404 = 'boards.views.page_404'
25 handler404 = NotFoundView.as_view()
@@ -15,7 +15,6 b''
15 post or its part (delimited by N characters) into quote of the new post.
15 post or its part (delimited by N characters) into quote of the new post.
16 [NOT STARTED] Ban confirmation page with reason
16 [NOT STARTED] Ban confirmation page with reason
17 [NOT STARTED] Post deletion confirmation page
17 [NOT STARTED] Post deletion confirmation page
18 [NOT STARTED] Moderating page. Tags editing and adding
19 [NOT STARTED] Get thread graph image using pygraphviz
18 [NOT STARTED] Get thread graph image using pygraphviz
20 [NOT STARTED] Subscribing to tag via AJAX
19 [NOT STARTED] Subscribing to tag via AJAX
21
20
1 NO CONTENT: file was removed
NO CONTENT: file was removed
General Comments 0
You need to be logged in to leave comments. Login now