##// END OF EJS Templates
Merged with default branch
neko259 -
r933:ce97c754 merge decentral
parent child Browse files
Show More
@@ -0,0 +1,23 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0001_initial'),
11 ]
12
13 operations = [
14 migrations.RemoveField(
15 model_name='post',
16 name='text_markup_type',
17 ),
18 migrations.AlterField(
19 model_name='post',
20 name='_text_rendered',
21 field=models.TextField(null=True, blank=True, editable=False),
22 ),
23 ]
@@ -0,0 +1,18 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0002_auto_20141118_2234'),
11 ]
12
13 operations = [
14 migrations.RemoveField(
15 model_name='tag',
16 name='threads',
17 ),
18 ]
@@ -0,0 +1,20 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0003_remove_tag_threads'),
11 ]
12
13 operations = [
14 migrations.AddField(
15 model_name='tag',
16 name='required',
17 field=models.BooleanField(default=False),
18 preserve_default=True,
19 ),
20 ]
This diff has been collapsed as it changes many lines, (1256 lines changed) Show them Hide them
@@ -0,0 +1,1256 b''
1 /**
2 * Centrifuge javascript client
3 * v0.5.2
4 */
5 ;(function () {
6 'use strict';
7
8 /**
9 * Oliver Caldwell
10 * http://oli.me.uk/2013/06/01/prototypical-inheritance-done-right/
11 */
12
13 if (!Object.create) {
14 Object.create = (function(){
15 function F(){}
16
17 return function(o){
18 if (arguments.length != 1) {
19 throw new Error('Object.create implementation only accepts one parameter.');
20 }
21 F.prototype = o;
22 return new F()
23 }
24 })()
25 }
26
27 if (!Array.prototype.indexOf) {
28 Array.prototype.indexOf = function (searchElement /*, fromIndex */) {
29 'use strict';
30 if (this == null) {
31 throw new TypeError();
32 }
33 var n, k, t = Object(this),
34 len = t.length >>> 0;
35
36 if (len === 0) {
37 return -1;
38 }
39 n = 0;
40 if (arguments.length > 1) {
41 n = Number(arguments[1]);
42 if (n != n) { // shortcut for verifying if it's NaN
43 n = 0;
44 } else if (n != 0 && n != Infinity && n != -Infinity) {
45 n = (n > 0 || -1) * Math.floor(Math.abs(n));
46 }
47 }
48 if (n >= len) {
49 return -1;
50 }
51 for (k = n >= 0 ? n : Math.max(len - Math.abs(n), 0); k < len; k++) {
52 if (k in t && t[k] === searchElement) {
53 return k;
54 }
55 }
56 return -1;
57 };
58 }
59
60 function extend(destination, source) {
61 destination.prototype = Object.create(source.prototype);
62 destination.prototype.constructor = destination;
63 return source.prototype;
64 }
65
66 /**
67 * EventEmitter v4.2.3 - git.io/ee
68 * Oliver Caldwell
69 * MIT license
70 * @preserve
71 */
72
73 /**
74 * Class for managing events.
75 * Can be extended to provide event functionality in other classes.
76 *
77 * @class EventEmitter Manages event registering and emitting.
78 */
79 function EventEmitter() {}
80
81 // Shortcuts to improve speed and size
82
83 // Easy access to the prototype
84 var proto = EventEmitter.prototype;
85
86 /**
87 * Finds the index of the listener for the event in it's storage array.
88 *
89 * @param {Function[]} listeners Array of listeners to search through.
90 * @param {Function} listener Method to look for.
91 * @return {Number} Index of the specified listener, -1 if not found
92 * @api private
93 */
94 function indexOfListener(listeners, listener) {
95 var i = listeners.length;
96 while (i--) {
97 if (listeners[i].listener === listener) {
98 return i;
99 }
100 }
101
102 return -1;
103 }
104
105 /**
106 * Alias a method while keeping the context correct, to allow for overwriting of target method.
107 *
108 * @param {String} name The name of the target method.
109 * @return {Function} The aliased method
110 * @api private
111 */
112 function alias(name) {
113 return function aliasClosure() {
114 return this[name].apply(this, arguments);
115 };
116 }
117
118 /**
119 * Returns the listener array for the specified event.
120 * Will initialise the event object and listener arrays if required.
121 * Will return an object if you use a regex search. The object contains keys for each matched event. So /ba[rz]/ might return an object containing bar and baz. But only if you have either defined them with defineEvent or added some listeners to them.
122 * Each property in the object response is an array of listener functions.
123 *
124 * @param {String|RegExp} evt Name of the event to return the listeners from.
125 * @return {Function[]|Object} All listener functions for the event.
126 */
127 proto.getListeners = function getListeners(evt) {
128 var events = this._getEvents();
129 var response;
130 var key;
131
132 // Return a concatenated array of all matching events if
133 // the selector is a regular expression.
134 if (typeof evt === 'object') {
135 response = {};
136 for (key in events) {
137 if (events.hasOwnProperty(key) && evt.test(key)) {
138 response[key] = events[key];
139 }
140 }
141 }
142 else {
143 response = events[evt] || (events[evt] = []);
144 }
145
146 return response;
147 };
148
149 /**
150 * Takes a list of listener objects and flattens it into a list of listener functions.
151 *
152 * @param {Object[]} listeners Raw listener objects.
153 * @return {Function[]} Just the listener functions.
154 */
155 proto.flattenListeners = function flattenListeners(listeners) {
156 var flatListeners = [];
157 var i;
158
159 for (i = 0; i < listeners.length; i += 1) {
160 flatListeners.push(listeners[i].listener);
161 }
162
163 return flatListeners;
164 };
165
166 /**
167 * Fetches the requested listeners via getListeners but will always return the results inside an object. This is mainly for internal use but others may find it useful.
168 *
169 * @param {String|RegExp} evt Name of the event to return the listeners from.
170 * @return {Object} All listener functions for an event in an object.
171 */
172 proto.getListenersAsObject = function getListenersAsObject(evt) {
173 var listeners = this.getListeners(evt);
174 var response;
175
176 if (listeners instanceof Array) {
177 response = {};
178 response[evt] = listeners;
179 }
180
181 return response || listeners;
182 };
183
184 /**
185 * Adds a listener function to the specified event.
186 * The listener will not be added if it is a duplicate.
187 * If the listener returns true then it will be removed after it is called.
188 * If you pass a regular expression as the event name then the listener will be added to all events that match it.
189 *
190 * @param {String|RegExp} evt Name of the event to attach the listener to.
191 * @param {Function} listener Method to be called when the event is emitted. If the function returns true then it will be removed after calling.
192 * @return {Object} Current instance of EventEmitter for chaining.
193 */
194 proto.addListener = function addListener(evt, listener) {
195 var listeners = this.getListenersAsObject(evt);
196 var listenerIsWrapped = typeof listener === 'object';
197 var key;
198
199 for (key in listeners) {
200 if (listeners.hasOwnProperty(key) && indexOfListener(listeners[key], listener) === -1) {
201 listeners[key].push(listenerIsWrapped ? listener : {
202 listener: listener,
203 once: false
204 });
205 }
206 }
207
208 return this;
209 };
210
211 /**
212 * Alias of addListener
213 */
214 proto.on = alias('addListener');
215
216 /**
217 * Semi-alias of addListener. It will add a listener that will be
218 * automatically removed after it's first execution.
219 *
220 * @param {String|RegExp} evt Name of the event to attach the listener to.
221 * @param {Function} listener Method to be called when the event is emitted. If the function returns true then it will be removed after calling.
222 * @return {Object} Current instance of EventEmitter for chaining.
223 */
224 proto.addOnceListener = function addOnceListener(evt, listener) {
225 //noinspection JSValidateTypes
226 return this.addListener(evt, {
227 listener: listener,
228 once: true
229 });
230 };
231
232 /**
233 * Alias of addOnceListener.
234 */
235 proto.once = alias('addOnceListener');
236
237 /**
238 * Defines an event name. This is required if you want to use a regex to add a listener to multiple events at once. If you don't do this then how do you expect it to know what event to add to? Should it just add to every possible match for a regex? No. That is scary and bad.
239 * You need to tell it what event names should be matched by a regex.
240 *
241 * @param {String} evt Name of the event to create.
242 * @return {Object} Current instance of EventEmitter for chaining.
243 */
244 proto.defineEvent = function defineEvent(evt) {
245 this.getListeners(evt);
246 return this;
247 };
248
249 /**
250 * Uses defineEvent to define multiple events.
251 *
252 * @param {String[]} evts An array of event names to define.
253 * @return {Object} Current instance of EventEmitter for chaining.
254 */
255 proto.defineEvents = function defineEvents(evts) {
256 for (var i = 0; i < evts.length; i += 1) {
257 this.defineEvent(evts[i]);
258 }
259 return this;
260 };
261
262 /**
263 * Removes a listener function from the specified event.
264 * When passed a regular expression as the event name, it will remove the listener from all events that match it.
265 *
266 * @param {String|RegExp} evt Name of the event to remove the listener from.
267 * @param {Function} listener Method to remove from the event.
268 * @return {Object} Current instance of EventEmitter for chaining.
269 */
270 proto.removeListener = function removeListener(evt, listener) {
271 var listeners = this.getListenersAsObject(evt);
272 var index;
273 var key;
274
275 for (key in listeners) {
276 if (listeners.hasOwnProperty(key)) {
277 index = indexOfListener(listeners[key], listener);
278
279 if (index !== -1) {
280 listeners[key].splice(index, 1);
281 }
282 }
283 }
284
285 return this;
286 };
287
288 /**
289 * Alias of removeListener
290 */
291 proto.off = alias('removeListener');
292
293 /**
294 * Adds listeners in bulk using the manipulateListeners method.
295 * If you pass an object as the second argument you can add to multiple events at once. The object should contain key value pairs of events and listeners or listener arrays. You can also pass it an event name and an array of listeners to be added.
296 * You can also pass it a regular expression to add the array of listeners to all events that match it.
297 * Yeah, this function does quite a bit. That's probably a bad thing.
298 *
299 * @param {String|Object|RegExp} evt An event name if you will pass an array of listeners next. An object if you wish to add to multiple events at once.
300 * @param {Function[]} [listeners] An optional array of listener functions to add.
301 * @return {Object} Current instance of EventEmitter for chaining.
302 */
303 proto.addListeners = function addListeners(evt, listeners) {
304 // Pass through to manipulateListeners
305 return this.manipulateListeners(false, evt, listeners);
306 };
307
308 /**
309 * Removes listeners in bulk using the manipulateListeners method.
310 * If you pass an object as the second argument you can remove from multiple events at once. The object should contain key value pairs of events and listeners or listener arrays.
311 * You can also pass it an event name and an array of listeners to be removed.
312 * You can also pass it a regular expression to remove the listeners from all events that match it.
313 *
314 * @param {String|Object|RegExp} evt An event name if you will pass an array of listeners next. An object if you wish to remove from multiple events at once.
315 * @param {Function[]} [listeners] An optional array of listener functions to remove.
316 * @return {Object} Current instance of EventEmitter for chaining.
317 */
318 proto.removeListeners = function removeListeners(evt, listeners) {
319 // Pass through to manipulateListeners
320 return this.manipulateListeners(true, evt, listeners);
321 };
322
323 /**
324 * Edits listeners in bulk. The addListeners and removeListeners methods both use this to do their job. You should really use those instead, this is a little lower level.
325 * The first argument will determine if the listeners are removed (true) or added (false).
326 * If you pass an object as the second argument you can add/remove from multiple events at once. The object should contain key value pairs of events and listeners or listener arrays.
327 * You can also pass it an event name and an array of listeners to be added/removed.
328 * You can also pass it a regular expression to manipulate the listeners of all events that match it.
329 *
330 * @param {Boolean} remove True if you want to remove listeners, false if you want to add.
331 * @param {String|Object|RegExp} evt An event name if you will pass an array of listeners next. An object if you wish to add/remove from multiple events at once.
332 * @param {Function[]} [listeners] An optional array of listener functions to add/remove.
333 * @return {Object} Current instance of EventEmitter for chaining.
334 */
335 proto.manipulateListeners = function manipulateListeners(remove, evt, listeners) {
336 var i;
337 var value;
338 var single = remove ? this.removeListener : this.addListener;
339 var multiple = remove ? this.removeListeners : this.addListeners;
340
341 // If evt is an object then pass each of it's properties to this method
342 if (typeof evt === 'object' && !(evt instanceof RegExp)) {
343 for (i in evt) {
344 if (evt.hasOwnProperty(i) && (value = evt[i])) {
345 // Pass the single listener straight through to the singular method
346 if (typeof value === 'function') {
347 single.call(this, i, value);
348 }
349 else {
350 // Otherwise pass back to the multiple function
351 multiple.call(this, i, value);
352 }
353 }
354 }
355 }
356 else {
357 // So evt must be a string
358 // And listeners must be an array of listeners
359 // Loop over it and pass each one to the multiple method
360 i = listeners.length;
361 while (i--) {
362 single.call(this, evt, listeners[i]);
363 }
364 }
365
366 return this;
367 };
368
369 /**
370 * Removes all listeners from a specified event.
371 * If you do not specify an event then all listeners will be removed.
372 * That means every event will be emptied.
373 * You can also pass a regex to remove all events that match it.
374 *
375 * @param {String|RegExp} [evt] Optional name of the event to remove all listeners for. Will remove from every event if not passed.
376 * @return {Object} Current instance of EventEmitter for chaining.
377 */
378 proto.removeEvent = function removeEvent(evt) {
379 var type = typeof evt;
380 var events = this._getEvents();
381 var key;
382
383 // Remove different things depending on the state of evt
384 if (type === 'string') {
385 // Remove all listeners for the specified event
386 delete events[evt];
387 }
388 else if (type === 'object') {
389 // Remove all events matching the regex.
390 for (key in events) {
391 //noinspection JSUnresolvedFunction
392 if (events.hasOwnProperty(key) && evt.test(key)) {
393 delete events[key];
394 }
395 }
396 }
397 else {
398 // Remove all listeners in all events
399 delete this._events;
400 }
401
402 return this;
403 };
404
405 /**
406 * Emits an event of your choice.
407 * When emitted, every listener attached to that event will be executed.
408 * If you pass the optional argument array then those arguments will be passed to every listener upon execution.
409 * Because it uses `apply`, your array of arguments will be passed as if you wrote them out separately.
410 * So they will not arrive within the array on the other side, they will be separate.
411 * You can also pass a regular expression to emit to all events that match it.
412 *
413 * @param {String|RegExp} evt Name of the event to emit and execute listeners for.
414 * @param {Array} [args] Optional array of arguments to be passed to each listener.
415 * @return {Object} Current instance of EventEmitter for chaining.
416 */
417 proto.emitEvent = function emitEvent(evt, args) {
418 var listeners = this.getListenersAsObject(evt);
419 var listener;
420 var i;
421 var key;
422 var response;
423
424 for (key in listeners) {
425 if (listeners.hasOwnProperty(key)) {
426 i = listeners[key].length;
427
428 while (i--) {
429 // If the listener returns true then it shall be removed from the event
430 // The function is executed either with a basic call or an apply if there is an args array
431 listener = listeners[key][i];
432
433 if (listener.once === true) {
434 this.removeListener(evt, listener.listener);
435 }
436
437 response = listener.listener.apply(this, args || []);
438
439 if (response === this._getOnceReturnValue()) {
440 this.removeListener(evt, listener.listener);
441 }
442 }
443 }
444 }
445
446 return this;
447 };
448
449 /**
450 * Alias of emitEvent
451 */
452 proto.trigger = alias('emitEvent');
453
454 //noinspection JSValidateJSDoc,JSCommentMatchesSignature
455 /**
456 * Subtly different from emitEvent in that it will pass its arguments on to the listeners, as opposed to taking a single array of arguments to pass on.
457 * As with emitEvent, you can pass a regex in place of the event name to emit to all events that match it.
458 *
459 * @param {String|RegExp} evt Name of the event to emit and execute listeners for.
460 * @param {...*} Optional additional arguments to be passed to each listener.
461 * @return {Object} Current instance of EventEmitter for chaining.
462 */
463 proto.emit = function emit(evt) {
464 var args = Array.prototype.slice.call(arguments, 1);
465 return this.emitEvent(evt, args);
466 };
467
468 /**
469 * Sets the current value to check against when executing listeners. If a
470 * listeners return value matches the one set here then it will be removed
471 * after execution. This value defaults to true.
472 *
473 * @param {*} value The new value to check for when executing listeners.
474 * @return {Object} Current instance of EventEmitter for chaining.
475 */
476 proto.setOnceReturnValue = function setOnceReturnValue(value) {
477 this._onceReturnValue = value;
478 return this;
479 };
480
481 /**
482 * Fetches the current value to check against when executing listeners. If
483 * the listeners return value matches this one then it should be removed
484 * automatically. It will return true by default.
485 *
486 * @return {*|Boolean} The current value to check for or the default, true.
487 * @api private
488 */
489 proto._getOnceReturnValue = function _getOnceReturnValue() {
490 if (this.hasOwnProperty('_onceReturnValue')) {
491 return this._onceReturnValue;
492 }
493 else {
494 return true;
495 }
496 };
497
498 /**
499 * Fetches the events object and creates one if required.
500 *
501 * @return {Object} The events storage object.
502 * @api private
503 */
504 proto._getEvents = function _getEvents() {
505 return this._events || (this._events = {});
506 };
507
508 /**
509 * Mixes in the given objects into the target object by copying the properties.
510 * @param deep if the copy must be deep
511 * @param target the target object
512 * @param objects the objects whose properties are copied into the target
513 */
514 function mixin(deep, target, objects) {
515 var result = target || {};
516
517 // Skip first 2 parameters (deep and target), and loop over the others
518 for (var i = 2; i < arguments.length; ++i) {
519 var object = arguments[i];
520
521 if (object === undefined || object === null) {
522 continue;
523 }
524
525 for (var propName in object) {
526 //noinspection JSUnfilteredForInLoop
527 var prop = fieldValue(object, propName);
528 //noinspection JSUnfilteredForInLoop
529 var targ = fieldValue(result, propName);
530
531 // Avoid infinite loops
532 if (prop === target) {
533 continue;
534 }
535 // Do not mixin undefined values
536 if (prop === undefined) {
537 continue;
538 }
539
540 if (deep && typeof prop === 'object' && prop !== null) {
541 if (prop instanceof Array) {
542 //noinspection JSUnfilteredForInLoop
543 result[propName] = mixin(deep, targ instanceof Array ? targ : [], prop);
544 } else {
545 var source = typeof targ === 'object' && !(targ instanceof Array) ? targ : {};
546 //noinspection JSUnfilteredForInLoop
547 result[propName] = mixin(deep, source, prop);
548 }
549 } else {
550 //noinspection JSUnfilteredForInLoop
551 result[propName] = prop;
552 }
553 }
554 }
555
556 return result;
557 }
558
559 function fieldValue(object, name) {
560 try {
561 return object[name];
562 } catch (x) {
563 return undefined;
564 }
565 }
566
567 function endsWith(value, suffix) {
568 return value.indexOf(suffix, value.length - suffix.length) !== -1;
569 }
570
571 function stripSlash(value) {
572 if (value.substring(value.length - 1) == "/") {
573 value = value.substring(0, value.length - 1);
574 }
575 return value;
576 }
577
578 function isString(value) {
579 if (value === undefined || value === null) {
580 return false;
581 }
582 return typeof value === 'string' || value instanceof String;
583 }
584
585 function isFunction(value) {
586 if (value === undefined || value === null) {
587 return false;
588 }
589 return typeof value === 'function';
590 }
591
592 function log(level, args) {
593 if (window.console) {
594 var logger = window.console[level];
595 if (isFunction(logger)) {
596 logger.apply(window.console, args);
597 }
598 }
599 }
600
601 function Centrifuge(options) {
602 this._sockjs = false;
603 this._status = 'disconnected';
604 this._reconnect = true;
605 this._transport = null;
606 this._messageId = 0;
607 this._clientId = null;
608 this._subscriptions = {};
609 this._messages = [];
610 this._isBatching = false;
611 this._config = {
612 retry: 3000,
613 info: null,
614 debug: false,
615 server: null,
616 protocols_whitelist: [
617 'websocket',
618 'xdr-streaming',
619 'xhr-streaming',
620 'iframe-eventsource',
621 'iframe-htmlfile',
622 'xdr-polling',
623 'xhr-polling',
624 'iframe-xhr-polling',
625 'jsonp-polling'
626 ]
627 };
628 if (options) {
629 this.configure(options);
630 }
631 }
632
633 extend(Centrifuge, EventEmitter);
634
635 var centrifuge_proto = Centrifuge.prototype;
636
637 centrifuge_proto._debug = function () {
638 if (this._config.debug === true) {
639 log('debug', arguments);
640 }
641 };
642
643 centrifuge_proto._configure = function (configuration) {
644 this._debug('Configuring centrifuge object with', configuration);
645
646 if (!configuration) {
647 configuration = {};
648 }
649
650 this._config = mixin(false, this._config, configuration);
651
652 if (!this._config.url) {
653 throw 'Missing required configuration parameter \'url\' specifying the Centrifuge server URL';
654 }
655
656 if (!this._config.token) {
657 throw 'Missing required configuration parameter \'token\' specifying the sign of authorization request';
658 }
659
660 if (!this._config.project) {
661 throw 'Missing required configuration parameter \'project\' specifying project ID in Centrifuge';
662 }
663
664 if (!this._config.user && this._config.user !== '') {
665 throw 'Missing required configuration parameter \'user\' specifying user\'s unique ID in your application';
666 }
667
668 if (!this._config.timestamp) {
669 throw 'Missing required configuration parameter \'timestamp\'';
670 }
671
672 this._config.url = stripSlash(this._config.url);
673
674 if (endsWith(this._config.url, 'connection')) {
675 //noinspection JSUnresolvedVariable
676 if (typeof window.SockJS === 'undefined') {
677 throw 'You need to include SockJS client library before Centrifuge javascript client library or use pure Websocket connection endpoint';
678 }
679 this._sockjs = true;
680 }
681 };
682
683 centrifuge_proto._setStatus = function (newStatus) {
684 if (this._status !== newStatus) {
685 this._debug('Status', this._status, '->', newStatus);
686 this._status = newStatus;
687 }
688 };
689
690 centrifuge_proto._isDisconnected = function () {
691 return this._isConnected() === false;
692 };
693
694 centrifuge_proto._isConnected = function () {
695 return this._status === 'connected';
696 };
697
698 centrifuge_proto._nextMessageId = function () {
699 return ++this._messageId;
700 };
701
702 centrifuge_proto._clearSubscriptions = function () {
703 this._subscriptions = {};
704 };
705
706 centrifuge_proto._send = function (messages) {
707 // We must be sure that the messages have a clientId.
708 // This is not guaranteed since the handshake may take time to return
709 // (and hence the clientId is not known yet) and the application
710 // may create other messages.
711 for (var i = 0; i < messages.length; ++i) {
712 var message = messages[i];
713 message.uid = '' + this._nextMessageId();
714
715 if (this._clientId) {
716 message.clientId = this._clientId;
717 }
718
719 this._debug('Send', message);
720 this._transport.send(JSON.stringify(message));
721 }
722 };
723
724 centrifuge_proto._connect = function (callback) {
725
726 this._clientId = null;
727
728 this._reconnect = true;
729
730 this._clearSubscriptions();
731
732 this._setStatus('connecting');
733
734 var self = this;
735
736 if (callback) {
737 this.on('connect', callback);
738 }
739
740 if (this._sockjs === true) {
741 //noinspection JSUnresolvedFunction
742 var sockjs_options = {
743 protocols_whitelist: this._config.protocols_whitelist
744 };
745 if (this._config.server !== null) {
746 sockjs_options['server'] = this._config.server;
747 }
748
749 this._transport = new SockJS(this._config.url, null, sockjs_options);
750
751 } else {
752 this._transport = new WebSocket(this._config.url);
753 }
754
755 this._setStatus('connecting');
756
757 this._transport.onopen = function () {
758
759 var centrifugeMessage = {
760 'method': 'connect',
761 'params': {
762 'token': self._config.token,
763 'user': self._config.user,
764 'project': self._config.project,
765 'timestamp': self._config.timestamp
766 }
767 };
768
769 if (self._config.info !== null) {
770 self._debug("connect using additional info");
771 centrifugeMessage['params']['info'] = self._config.info;
772 } else {
773 self._debug("connect without additional info");
774 }
775 self.send(centrifugeMessage);
776 };
777
778 this._transport.onerror = function (error) {
779 self._debug(error);
780 };
781
782 this._transport.onclose = function () {
783 self._setStatus('disconnected');
784 self.trigger('disconnect');
785 if (self._reconnect === true) {
786 window.setTimeout(function () {
787 if (self._reconnect === true) {
788 self._connect.call(self);
789 }
790 }, self._config.retry);
791 }
792 };
793
794 this._transport.onmessage = function (event) {
795 var data;
796 data = JSON.parse(event.data);
797 self._debug('Received', data);
798 self._receive(data);
799 };
800 };
801
802 centrifuge_proto._disconnect = function () {
803 this._clientId = null;
804 this._setStatus('disconnected');
805 this._subscriptions = {};
806 this._reconnect = false;
807 this._transport.close();
808 };
809
810 centrifuge_proto._getSubscription = function (channel) {
811 var subscription;
812 subscription = this._subscriptions[channel];
813 if (!subscription) {
814 return null;
815 }
816 return subscription;
817 };
818
819 centrifuge_proto._removeSubscription = function (channel) {
820 try {
821 delete this._subscriptions[channel];
822 } catch (e) {
823 this._debug('nothing to delete for channel ', channel);
824 }
825 };
826
827 centrifuge_proto._connectResponse = function (message) {
828 if (message.error === null) {
829 this._clientId = message.body;
830 this._setStatus('connected');
831 this.trigger('connect', [message]);
832 } else {
833 this.trigger('error', [message]);
834 this.trigger('connect:error', [message]);
835 }
836 };
837
838 centrifuge_proto._disconnectResponse = function (message) {
839 if (message.error === null) {
840 this.disconnect();
841 //this.trigger('disconnect', [message]);
842 //this.trigger('disconnect:success', [message]);
843 } else {
844 this.trigger('error', [message]);
845 this.trigger('disconnect:error', [message.error]);
846 }
847 };
848
849 centrifuge_proto._subscribeResponse = function (message) {
850 if (message.error !== null) {
851 this.trigger('error', [message]);
852 }
853 var body = message.body;
854 if (body === null) {
855 return;
856 }
857 var channel = body.channel;
858 var subscription = this.getSubscription(channel);
859 if (!subscription) {
860 return;
861 }
862 if (message.error === null) {
863 subscription.trigger('subscribe:success', [body]);
864 subscription.trigger('ready', [body]);
865 } else {
866 subscription.trigger('subscribe:error', [message.error]);
867 subscription.trigger('error', [message]);
868 }
869 };
870
871 centrifuge_proto._unsubscribeResponse = function (message) {
872 var body = message.body;
873 var channel = body.channel;
874 var subscription = this.getSubscription(channel);
875 if (!subscription) {
876 return;
877 }
878 if (message.error === null) {
879 subscription.trigger('unsubscribe', [body]);
880 this._centrifuge._removeSubscription(channel);
881 }
882 };
883
884 centrifuge_proto._publishResponse = function (message) {
885 var body = message.body;
886 var channel = body.channel;
887 var subscription = this.getSubscription(channel);
888 if (!subscription) {
889 return;
890 }
891 if (message.error === null) {
892 subscription.trigger('publish:success', [body]);
893 } else {
894 subscription.trigger('publish:error', [message.error]);
895 this.trigger('error', [message]);
896 }
897 };
898
899 centrifuge_proto._presenceResponse = function (message) {
900 var body = message.body;
901 var channel = body.channel;
902 var subscription = this.getSubscription(channel);
903 if (!subscription) {
904 return;
905 }
906 if (message.error === null) {
907 subscription.trigger('presence', [body]);
908 subscription.trigger('presence:success', [body]);
909 } else {
910 subscription.trigger('presence:error', [message.error]);
911 this.trigger('error', [message]);
912 }
913 };
914
915 centrifuge_proto._historyResponse = function (message) {
916 var body = message.body;
917 var channel = body.channel;
918 var subscription = this.getSubscription(channel);
919 if (!subscription) {
920 return;
921 }
922 if (message.error === null) {
923 subscription.trigger('history', [body]);
924 subscription.trigger('history:success', [body]);
925 } else {
926 subscription.trigger('history:error', [message.error]);
927 this.trigger('error', [message]);
928 }
929 };
930
931 centrifuge_proto._joinResponse = function(message) {
932 var body = message.body;
933 var channel = body.channel;
934 var subscription = this.getSubscription(channel);
935 if (!subscription) {
936 return;
937 }
938 subscription.trigger('join', [body]);
939 };
940
941 centrifuge_proto._leaveResponse = function(message) {
942 var body = message.body;
943 var channel = body.channel;
944 var subscription = this.getSubscription(channel);
945 if (!subscription) {
946 return;
947 }
948 subscription.trigger('leave', [body]);
949 };
950
951 centrifuge_proto._messageResponse = function (message) {
952 var body = message.body;
953 var channel = body.channel;
954 var subscription = this.getSubscription(channel);
955 if (subscription === null) {
956 return;
957 }
958 subscription.trigger('message', [body]);
959 };
960
961 centrifuge_proto._dispatchMessage = function(message) {
962 if (message === undefined || message === null) {
963 return;
964 }
965
966 var method = message.method;
967
968 if (!method) {
969 return;
970 }
971
972 switch (method) {
973 case 'connect':
974 this._connectResponse(message);
975 break;
976 case 'disconnect':
977 this._disconnectResponse(message);
978 break;
979 case 'subscribe':
980 this._subscribeResponse(message);
981 break;
982 case 'unsubscribe':
983 this._unsubscribeResponse(message);
984 break;
985 case 'publish':
986 this._publishResponse(message);
987 break;
988 case 'presence':
989 this._presenceResponse(message);
990 break;
991 case 'history':
992 this._historyResponse(message);
993 break;
994 case 'join':
995 this._joinResponse(message);
996 break;
997 case 'leave':
998 this._leaveResponse(message);
999 break;
1000 case 'ping':
1001 break;
1002 case 'message':
1003 this._messageResponse(message);
1004 break;
1005 default:
1006 break;
1007 }
1008 };
1009
1010 centrifuge_proto._receive = function (data) {
1011 if (Object.prototype.toString.call(data) === Object.prototype.toString.call([])) {
1012 for (var i in data) {
1013 if (data.hasOwnProperty(i)) {
1014 var msg = data[i];
1015 this._dispatchMessage(msg);
1016 }
1017 }
1018 } else if (Object.prototype.toString.call(data) === Object.prototype.toString.call({})) {
1019 this._dispatchMessage(data);
1020 }
1021 };
1022
1023 centrifuge_proto._flush = function() {
1024 var messages = this._messages.slice(0);
1025 this._messages = [];
1026 this._send(messages);
1027 };
1028
1029 centrifuge_proto._ping = function () {
1030 var centrifugeMessage = {
1031 "method": "ping",
1032 "params": {}
1033 };
1034 this.send(centrifugeMessage);
1035 };
1036
1037 /* PUBLIC API */
1038
1039 centrifuge_proto.getClientId = function () {
1040 return this._clientId;
1041 };
1042
1043 centrifuge_proto.isConnected = centrifuge_proto._isConnected;
1044
1045 centrifuge_proto.isDisconnected = centrifuge_proto._isDisconnected;
1046
1047 centrifuge_proto.configure = function (configuration) {
1048 this._configure.call(this, configuration);
1049 };
1050
1051 centrifuge_proto.connect = centrifuge_proto._connect;
1052
1053 centrifuge_proto.disconnect = centrifuge_proto._disconnect;
1054
1055 centrifuge_proto.getSubscription = centrifuge_proto._getSubscription;
1056
1057 centrifuge_proto.ping = centrifuge_proto._ping;
1058
1059 centrifuge_proto.send = function (message) {
1060 if (this._isBatching === true) {
1061 this._messages.push(message);
1062 } else {
1063 this._send([message]);
1064 }
1065 };
1066
1067 centrifuge_proto.startBatching = function () {
1068 // start collecting messages without sending them to Centrifuge until flush
1069 // method called
1070 this._isBatching = true;
1071 };
1072
1073 centrifuge_proto.stopBatching = function(flush) {
1074 // stop collecting messages
1075 flush = flush || false;
1076 this._isBatching = false;
1077 if (flush === true) {
1078 this.flush();
1079 }
1080 };
1081
1082 centrifuge_proto.flush = function() {
1083 this._flush();
1084 };
1085
1086 centrifuge_proto.subscribe = function (channel, callback) {
1087
1088 if (arguments.length < 1) {
1089 throw 'Illegal arguments number: required 1, got ' + arguments.length;
1090 }
1091 if (!isString(channel)) {
1092 throw 'Illegal argument type: channel must be a string';
1093 }
1094 if (this.isDisconnected()) {
1095 throw 'Illegal state: already disconnected';
1096 }
1097
1098 var current_subscription = this.getSubscription(channel);
1099
1100 if (current_subscription !== null) {
1101 return current_subscription;
1102 } else {
1103 var subscription = new Subscription(this, channel);
1104 this._subscriptions[channel] = subscription;
1105 subscription.subscribe(callback);
1106 return subscription;
1107 }
1108 };
1109
1110 centrifuge_proto.unsubscribe = function (channel) {
1111 if (arguments.length < 1) {
1112 throw 'Illegal arguments number: required 1, got ' + arguments.length;
1113 }
1114 if (!isString(channel)) {
1115 throw 'Illegal argument type: channel must be a string';
1116 }
1117 if (this.isDisconnected()) {
1118 return;
1119 }
1120
1121 var subscription = this.getSubscription(channel);
1122 if (subscription !== null) {
1123 subscription.unsubscribe();
1124 }
1125 };
1126
1127 centrifuge_proto.publish = function (channel, data, callback) {
1128 var subscription = this.getSubscription(channel);
1129 if (subscription === null) {
1130 this._debug("subscription not found for channel " + channel);
1131 return null;
1132 }
1133 subscription.publish(data, callback);
1134 return subscription;
1135 };
1136
1137 centrifuge_proto.presence = function (channel, callback) {
1138 var subscription = this.getSubscription(channel);
1139 if (subscription === null) {
1140 this._debug("subscription not found for channel " + channel);
1141 return null;
1142 }
1143 subscription.presence(callback);
1144 return subscription;
1145 };
1146
1147 centrifuge_proto.history = function (channel, callback) {
1148 var subscription = this.getSubscription(channel);
1149 if (subscription === null) {
1150 this._debug("subscription not found for channel " + channel);
1151 return null;
1152 }
1153 subscription.history(callback);
1154 return subscription;
1155 };
1156
1157 function Subscription(centrifuge, channel) {
1158 /**
1159 * The constructor for a centrifuge object, identified by an optional name.
1160 * The default name is the string 'default'.
1161 * @param name the optional name of this centrifuge object
1162 */
1163 this._centrifuge = centrifuge;
1164 this.channel = channel;
1165 }
1166
1167 extend(Subscription, EventEmitter);
1168
1169 var sub_proto = Subscription.prototype;
1170
1171 sub_proto.getChannel = function () {
1172 return this.channel;
1173 };
1174
1175 sub_proto.getCentrifuge = function () {
1176 return this._centrifuge;
1177 };
1178
1179 sub_proto.subscribe = function (callback) {
1180 var centrifugeMessage = {
1181 "method": "subscribe",
1182 "params": {
1183 "channel": this.channel
1184 }
1185 };
1186 this._centrifuge.send(centrifugeMessage);
1187 if (callback) {
1188 this.on('message', callback);
1189 }
1190 };
1191
1192 sub_proto.unsubscribe = function () {
1193 this._centrifuge._removeSubscription(this.channel);
1194 var centrifugeMessage = {
1195 "method": "unsubscribe",
1196 "params": {
1197 "channel": this.channel
1198 }
1199 };
1200 this._centrifuge.send(centrifugeMessage);
1201 };
1202
1203 sub_proto.publish = function (data, callback) {
1204 var centrifugeMessage = {
1205 "method": "publish",
1206 "params": {
1207 "channel": this.channel,
1208 "data": data
1209 }
1210 };
1211 if (callback) {
1212 this.on('publish:success', callback);
1213 }
1214 this._centrifuge.send(centrifugeMessage);
1215 };
1216
1217 sub_proto.presence = function (callback) {
1218 var centrifugeMessage = {
1219 "method": "presence",
1220 "params": {
1221 "channel": this.channel
1222 }
1223 };
1224 if (callback) {
1225 this.on('presence', callback);
1226 }
1227 this._centrifuge.send(centrifugeMessage);
1228 };
1229
1230 sub_proto.history = function (callback) {
1231 var centrifugeMessage = {
1232 "method": "history",
1233 "params": {
1234 "channel": this.channel
1235 }
1236 };
1237 if (callback) {
1238 this.on('history', callback);
1239 }
1240 this._centrifuge.send(centrifugeMessage);
1241 };
1242
1243 // Expose the class either via AMD, CommonJS or the global object
1244 if (typeof define === 'function' && define.amd) {
1245 define(function () {
1246 return Centrifuge;
1247 });
1248 } else if (typeof module === 'object' && module.exports) {
1249 //noinspection JSUnresolvedVariable
1250 module.exports = Centrifuge;
1251 } else {
1252 //noinspection JSUnusedGlobalSymbols
1253 this.Centrifuge = Centrifuge;
1254 }
1255
1256 }.call(this));
@@ -0,0 +1,27 b''
1 from django.test import TestCase
2 from boards.models import Post
3
4
5 class ParserTest(TestCase):
6 def test_preparse_quote(self):
7 raw_text = '>quote\nQuote in >line\nLine\n>Quote'
8 preparsed_text = Post.objects._preparse_text(raw_text)
9
10 self.assertEqual(
11 '[quote]quote[/quote]\nQuote in >line\nLine\n[quote]Quote[/quote]',
12 preparsed_text, 'Quote not preparsed.')
13
14 def test_preparse_comment(self):
15 raw_text = '//comment'
16 preparsed_text = Post.objects._preparse_text(raw_text)
17
18 self.assertEqual('[comment]comment[/comment]', preparsed_text,
19 'Comment not preparsed.')
20
21 def test_preparse_reflink(self):
22 raw_text = '>>12\nText'
23 preparsed_text = Post.objects._preparse_text(raw_text)
24
25 self.assertEqual('[post]12[/post]\nText',
26 preparsed_text, 'Reflink not preparsed.')
27
@@ -0,0 +1,10 b''
1 [Unit]
2 Description=Neboard imageboard
3 After=network.target
4
5 [Service]
6 ExecStart=/usr/bin/uwsgi_python33 --ini uwsgi.ini
7 WorkingDirectory=<where is your neboard located>
8
9 [Install]
10 WantedBy=multi-user.target
@@ -0,0 +1,11 b''
1 [uwsgi]
2 module = neboard.wsgi:application
3 master = true
4 pidfile = /tmp/neboard.pid
5 socket = 127.0.0.1:8080
6 processes = 5
7 harakiri = 20
8 max-requests = 5000
9 disable-logging = true
10 vacuum = true
11 # socket=/var/run/neboard.sock
@@ -1,18 +1,22 b''
1 1 bc8fce57a613175450b8b6d933cdd85f22c04658 1.1
2 2 784258eb652c563c288ca7652c33f52cd4733d83 1.1-stable
3 3 1b53a22467a8fccc798935d7a26efe78e4bc7b25 1.2-stable
4 4 1713fb7543386089e364c39703b79e57d3d851f0 1.3
5 5 80f183ebbe132ea8433eacae9431360f31fe7083 1.4
6 6 4330ff5a2bf6c543d8aaae8a43de1dc062f3bd13 1.4.1
7 7 8531d7b001392289a6b761f38c73a257606552ad 1.5
8 8 78e843c8b04b5a81cee5aa24601e305fae75da24 1.5.1
9 9 4f92838730ed9aa1d17651bbcdca19a097fd0c37 1.6
10 10 4bac2f37ea463337ddd27f98e7985407a74de504 1.7
11 11 1c4febea92c6503ae557fba73b2768659ae90d24 1.7.1
12 12 56a4a4578fc454ee455e33dd74a2cc82234bcb59 1.7.2
13 13 34d6f3d5deb22be56b6c1512ec654bd7f6e03bcc 1.7.3
14 14 f5cca33d29c673b67d43f310bebc4e3a21c6d04c 1.7.4
15 15 7f7c33ba6e3f3797ca866c5ed5d358a2393f1371 1.8
16 16 a6b9dd9547bdc17b681502efcceb17aa5c09adf4 1.8.1
17 17 8318fa1615d1946e4519f5735ae880909521990d 2.0
18 18 e23590ee7e2067a3f0fc3cbcfd66404b47127feb 2.1
19 4d998aba79e4abf0a2e78e93baaa2c2800b1c49c 2.2
20 07fdef4ac33a859250d03f17c594089792bca615 2.2.1
21 bcc74d45f060ecd3ff06ff448165aea0d026cb3e 2.2.2
22 b0e629ff24eb47a449ecfb455dc6cc600d18c9e2 2.2.3
@@ -1,146 +1,147 b''
1 1 from django.shortcuts import get_object_or_404
2 2 from boards.models import Tag
3 3
4 4 __author__ = 'neko259'
5 5
6 6 SESSION_SETTING = 'setting'
7 7
8 # Remove this, it is not used any more cause there is a user's permission
8 9 PERMISSION_MODERATE = 'moderator'
9 10
10 11 SETTING_THEME = 'theme'
11 12 SETTING_FAVORITE_TAGS = 'favorite_tags'
12 13 SETTING_HIDDEN_TAGS = 'hidden_tags'
13 14 SETTING_PERMISSIONS = 'permissions'
14 15
15 16 DEFAULT_THEME = 'md'
16 17
17 18
18 19 def get_settings_manager(request):
19 20 """
20 21 Get settings manager based on the request object. Currently only
21 22 session-based manager is supported. In the future, cookie-based or
22 23 database-based managers could be implemented.
23 24 """
24 25 return SessionSettingsManager(request.session)
25 26
26 27
27 28 class SettingsManager:
28 29 """
29 30 Base settings manager class. get_setting and set_setting methods should
30 31 be overriden.
31 32 """
32 33 def __init__(self):
33 34 pass
34 35
35 36 def get_theme(self):
36 37 theme = self.get_setting(SETTING_THEME)
37 38 if not theme:
38 39 theme = DEFAULT_THEME
39 40 self.set_setting(SETTING_THEME, theme)
40 41
41 42 return theme
42 43
43 44 def set_theme(self, theme):
44 45 self.set_setting(SETTING_THEME, theme)
45 46
46 47 def has_permission(self, permission):
47 48 permissions = self.get_setting(SETTING_PERMISSIONS)
48 49 if permissions:
49 50 return permission in permissions
50 51 else:
51 52 return False
52 53
53 54 def get_setting(self, setting):
54 55 pass
55 56
56 57 def set_setting(self, setting, value):
57 58 pass
58 59
59 60 def add_permission(self, permission):
60 61 permissions = self.get_setting(SETTING_PERMISSIONS)
61 62 if not permissions:
62 63 permissions = [permission]
63 64 else:
64 65 permissions.append(permission)
65 66 self.set_setting(SETTING_PERMISSIONS, permissions)
66 67
67 68 def del_permission(self, permission):
68 69 permissions = self.get_setting(SETTING_PERMISSIONS)
69 70 if not permissions:
70 71 permissions = []
71 72 else:
72 73 permissions.remove(permission)
73 74 self.set_setting(SETTING_PERMISSIONS, permissions)
74 75
75 76 def get_fav_tags(self):
76 77 tag_names = self.get_setting(SETTING_FAVORITE_TAGS)
77 78 tags = []
78 79 if tag_names:
79 80 for tag_name in tag_names:
80 81 tag = get_object_or_404(Tag, name=tag_name)
81 82 tags.append(tag)
82 83 return tags
83 84
84 85 def add_fav_tag(self, tag):
85 86 tags = self.get_setting(SETTING_FAVORITE_TAGS)
86 87 if not tags:
87 88 tags = [tag.name]
88 89 else:
89 90 if not tag.name in tags:
90 91 tags.append(tag.name)
91 92
92 93 tags.sort()
93 94 self.set_setting(SETTING_FAVORITE_TAGS, tags)
94 95
95 96 def del_fav_tag(self, tag):
96 97 tags = self.get_setting(SETTING_FAVORITE_TAGS)
97 98 if tag.name in tags:
98 99 tags.remove(tag.name)
99 100 self.set_setting(SETTING_FAVORITE_TAGS, tags)
100 101
101 102 def get_hidden_tags(self):
102 103 tag_names = self.get_setting(SETTING_HIDDEN_TAGS)
103 104 tags = []
104 105 if tag_names:
105 106 for tag_name in tag_names:
106 107 tag = get_object_or_404(Tag, name=tag_name)
107 108 tags.append(tag)
108 109
109 110 return tags
110 111
111 112 def add_hidden_tag(self, tag):
112 113 tags = self.get_setting(SETTING_HIDDEN_TAGS)
113 114 if not tags:
114 115 tags = [tag.name]
115 116 else:
116 117 if not tag.name in tags:
117 118 tags.append(tag.name)
118 119
119 120 tags.sort()
120 121 self.set_setting(SETTING_HIDDEN_TAGS, tags)
121 122
122 123 def del_hidden_tag(self, tag):
123 124 tags = self.get_setting(SETTING_HIDDEN_TAGS)
124 125 if tag.name in tags:
125 126 tags.remove(tag.name)
126 127 self.set_setting(SETTING_HIDDEN_TAGS, tags)
127 128
128 129
129 130 class SessionSettingsManager(SettingsManager):
130 131 """
131 132 Session-based settings manager. All settings are saved to the user's
132 133 session.
133 134 """
134 135 def __init__(self, session):
135 136 SettingsManager.__init__(self)
136 137 self.session = session
137 138
138 139 def get_setting(self, setting):
139 140 if setting in self.session:
140 141 return self.session[setting]
141 142 else:
142 143 return None
143 144
144 145 def set_setting(self, setting, value):
145 146 self.session[setting] = value
146 147
@@ -1,43 +1,54 b''
1 1 from django.contrib import admin
2 2 from boards.models import Post, Tag, Ban, Thread, KeyPair
3 3
4 4
5 @admin.register(Post)
5 6 class PostAdmin(admin.ModelAdmin):
6 7
7 8 list_display = ('id', 'title', 'text')
8 9 list_filter = ('pub_time', 'thread_new')
9 10 search_fields = ('id', 'title', 'text')
11 exclude = ('referenced_posts', 'refmap')
12 readonly_fields = ('poster_ip', 'thread_new')
10 13
11 14
15 @admin.register(Tag)
12 16 class TagAdmin(admin.ModelAdmin):
13 17
14 list_display = ('name',)
18 def thread_count(self, obj: Tag) -> int:
19 return obj.get_thread_count()
15 20
21 list_display = ('name', 'thread_count')
22 search_fields = ('name',)
23
24
25 @admin.register(Thread)
16 26 class ThreadAdmin(admin.ModelAdmin):
17 27
18 def title(self, obj):
19 return obj.get_opening_post().title
28 def title(self, obj: Thread) -> str:
29 return obj.get_opening_post().get_title()
20 30
21 def reply_count(self, obj):
31 def reply_count(self, obj: Thread) -> int:
22 32 return obj.get_reply_count()
23 33
24 list_display = ('id', 'title', 'reply_count', 'archived')
25 list_filter = ('bump_time', 'archived')
34 def ip(self, obj: Thread):
35 return obj.get_opening_post().poster_ip
36
37 list_display = ('id', 'title', 'reply_count', 'archived', 'ip')
38 list_filter = ('bump_time', 'archived', 'bumpable')
26 39 search_fields = ('id', 'title')
40 filter_horizontal = ('tags',)
27 41
28 42
43 @admin.register(KeyPair)
29 44 class KeyPairAdmin(admin.ModelAdmin):
30 45 list_display = ('public_key', 'primary')
31 46 list_filter = ('primary',)
32 47 search_fields = ('public_key',)
33 48
49
50 @admin.register(Ban)
34 51 class BanAdmin(admin.ModelAdmin):
35 52 list_display = ('ip', 'can_read')
36 53 list_filter = ('can_read',)
37 54 search_fields = ('ip',)
38
39 admin.site.register(Post, PostAdmin)
40 admin.site.register(Tag, TagAdmin)
41 admin.site.register(Ban, BanAdmin)
42 admin.site.register(Thread, ThreadAdmin)
43 admin.site.register(KeyPair, KeyPairAdmin)
@@ -1,42 +1,41 b''
1 from boards.abstracts.settingsmanager import PERMISSION_MODERATE, \
2 get_settings_manager
1 from boards.abstracts.settingsmanager import get_settings_manager
3 2
4 3 __author__ = 'neko259'
5 4
6 5 from boards import settings
7 6 from boards.models import Post
8 7
9 8 CONTEXT_SITE_NAME = 'site_name'
10 9 CONTEXT_VERSION = 'version'
11 10 CONTEXT_MODERATOR = 'moderator'
12 11 CONTEXT_THEME_CSS = 'theme_css'
13 12 CONTEXT_THEME = 'theme'
14 13 CONTEXT_PPD = 'posts_per_day'
15 14 CONTEXT_TAGS = 'tags'
16 15 CONTEXT_USER = 'user'
17 16
18 17 PERMISSION_MODERATE = 'moderation'
19 18
20 19
21 20 def user_and_ui_processor(request):
22 context = {}
21 context = dict()
23 22
24 23 context[CONTEXT_PPD] = float(Post.objects.get_posts_per_day())
25 24
26 25 settings_manager = get_settings_manager(request)
27 26 context[CONTEXT_TAGS] = settings_manager.get_fav_tags()
28 27 theme = settings_manager.get_theme()
29 28 context[CONTEXT_THEME] = theme
30 29 context[CONTEXT_THEME_CSS] = 'css/' + theme + '/base_page.css'
31 30
32 31 # This shows the moderator panel
33 32 try:
34 moderate = request.user.has_perm('moderation')
33 moderate = request.user.has_perm(PERMISSION_MODERATE)
35 34 except AttributeError:
36 35 moderate = False
37 36 context[CONTEXT_MODERATOR] = moderate
38 37
39 38 context[CONTEXT_VERSION] = settings.VERSION
40 39 context[CONTEXT_SITE_NAME] = settings.SITE_NAME
41 40
42 41 return context
@@ -1,294 +1,305 b''
1 1 import re
2 2 import time
3 3 import hashlib
4 4
5 5 from django import forms
6 6 from django.forms.util import ErrorList
7 7 from django.utils.translation import ugettext_lazy as _
8 8
9 9 from boards.mdx_neboard import formatters
10 10 from boards.models.post import TITLE_MAX_LENGTH
11 from boards.models import PostImage
11 from boards.models import PostImage, Tag
12 12 from neboard import settings
13 13 from boards import utils
14 14 import boards.settings as board_settings
15 15
16 16 VETERAN_POSTING_DELAY = 5
17 17
18 18 ATTRIBUTE_PLACEHOLDER = 'placeholder'
19 19
20 20 LAST_POST_TIME = 'last_post_time'
21 21 LAST_LOGIN_TIME = 'last_login_time'
22 22 TEXT_PLACEHOLDER = _('''Type message here. Use formatting panel for more advanced usage.''')
23 23 TAGS_PLACEHOLDER = _('tag1 several_words_tag')
24 24
25 25 ERROR_IMAGE_DUPLICATE = _('Such image was already posted')
26 26
27 27 LABEL_TITLE = _('Title')
28 28 LABEL_TEXT = _('Text')
29 29 LABEL_TAG = _('Tag')
30 30 LABEL_SEARCH = _('Search')
31 31
32 32 TAG_MAX_LENGTH = 20
33 33
34 34 REGEX_TAG = r'^[\w\d]+$'
35 35
36 36
37 37 class FormatPanel(forms.Textarea):
38 38 def render(self, name, value, attrs=None):
39 39 output = '<div id="mark-panel">'
40 40 for formatter in formatters:
41 41 output += '<span class="mark_btn"' + \
42 42 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
43 43 '\', \'' + formatter.format_right + '\')">' + \
44 44 formatter.preview_left + formatter.name + \
45 45 formatter.preview_right + '</span>'
46 46
47 47 output += '</div>'
48 48 output += super(FormatPanel, self).render(name, value, attrs=None)
49 49
50 50 return output
51 51
52 52
53 53 class PlainErrorList(ErrorList):
54 54 def __unicode__(self):
55 55 return self.as_text()
56 56
57 57 def as_text(self):
58 58 return ''.join(['(!) %s ' % e for e in self])
59 59
60 60
61 61 class NeboardForm(forms.Form):
62 62
63 63 def as_div(self):
64 64 """
65 65 Returns this form rendered as HTML <as_div>s.
66 66 """
67 67
68 68 return self._html_output(
69 69 # TODO Do not show hidden rows in the list here
70 70 normal_row='<div class="form-row"><div class="form-label">'
71 71 '%(label)s'
72 72 '</div></div>'
73 73 '<div class="form-row"><div class="form-input">'
74 74 '%(field)s'
75 75 '</div></div>'
76 76 '<div class="form-row">'
77 77 '%(help_text)s'
78 78 '</div>',
79 79 error_row='<div class="form-row">'
80 80 '<div class="form-label"></div>'
81 81 '<div class="form-errors">%s</div>'
82 82 '</div>',
83 83 row_ender='</div>',
84 84 help_text_html='%s',
85 85 errors_on_separate_row=True)
86 86
87 87 def as_json_errors(self):
88 88 errors = []
89 89
90 90 for name, field in list(self.fields.items()):
91 91 if self[name].errors:
92 92 errors.append({
93 93 'field': name,
94 94 'errors': self[name].errors.as_text(),
95 95 })
96 96
97 97 return errors
98 98
99 99
100 100 class PostForm(NeboardForm):
101 101
102 102 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
103 103 label=LABEL_TITLE)
104 104 text = forms.CharField(
105 105 widget=FormatPanel(attrs={ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER}),
106 106 required=False, label=LABEL_TEXT)
107 107 image = forms.ImageField(required=False, label=_('Image'),
108 108 widget=forms.ClearableFileInput(
109 109 attrs={'accept': 'image/*'}))
110 110
111 111 # This field is for spam prevention only
112 112 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
113 113 widget=forms.TextInput(attrs={
114 114 'class': 'form-email'}))
115 115
116 116 session = None
117 117 need_to_ban = False
118 118
119 119 def clean_title(self):
120 120 title = self.cleaned_data['title']
121 121 if title:
122 122 if len(title) > TITLE_MAX_LENGTH:
123 123 raise forms.ValidationError(_('Title must have less than %s '
124 124 'characters') %
125 125 str(TITLE_MAX_LENGTH))
126 126 return title
127 127
128 128 def clean_text(self):
129 129 text = self.cleaned_data['text'].strip()
130 130 if text:
131 131 if len(text) > board_settings.MAX_TEXT_LENGTH:
132 132 raise forms.ValidationError(_('Text must have less than %s '
133 133 'characters') %
134 134 str(board_settings
135 135 .MAX_TEXT_LENGTH))
136 136 return text
137 137
138 138 def clean_image(self):
139 139 image = self.cleaned_data['image']
140 140 if image:
141 141 if image.size > board_settings.MAX_IMAGE_SIZE:
142 142 raise forms.ValidationError(
143 143 _('Image must be less than %s bytes')
144 144 % str(board_settings.MAX_IMAGE_SIZE))
145 145
146 146 md5 = hashlib.md5()
147 147 for chunk in image.chunks():
148 148 md5.update(chunk)
149 149 image_hash = md5.hexdigest()
150 150 if PostImage.objects.filter(hash=image_hash).exists():
151 151 raise forms.ValidationError(ERROR_IMAGE_DUPLICATE)
152 152
153 153 return image
154 154
155 155 def clean(self):
156 156 cleaned_data = super(PostForm, self).clean()
157 157
158 158 if not self.session:
159 159 raise forms.ValidationError('Humans have sessions')
160 160
161 161 if cleaned_data['email']:
162 162 self.need_to_ban = True
163 163 raise forms.ValidationError('A human cannot enter a hidden field')
164 164
165 165 if not self.errors:
166 166 self._clean_text_image()
167 167
168 168 if not self.errors and self.session:
169 169 self._validate_posting_speed()
170 170
171 171 return cleaned_data
172 172
173 173 def _clean_text_image(self):
174 174 text = self.cleaned_data.get('text')
175 175 image = self.cleaned_data.get('image')
176 176
177 177 if (not text) and (not image):
178 178 error_message = _('Either text or image must be entered.')
179 179 self._errors['text'] = self.error_class([error_message])
180 180
181 181 def _validate_posting_speed(self):
182 182 can_post = True
183 183
184 184 posting_delay = settings.POSTING_DELAY
185 185
186 186 if board_settings.LIMIT_POSTING_SPEED and LAST_POST_TIME in \
187 187 self.session:
188 188 now = time.time()
189 189 last_post_time = self.session[LAST_POST_TIME]
190 190
191 191 current_delay = int(now - last_post_time)
192 192
193 193 if current_delay < posting_delay:
194 194 error_message = _('Wait %s seconds after last posting') % str(
195 195 posting_delay - current_delay)
196 196 self._errors['text'] = self.error_class([error_message])
197 197
198 198 can_post = False
199 199
200 200 if can_post:
201 201 self.session[LAST_POST_TIME] = time.time()
202 202
203 203
204 204 class ThreadForm(PostForm):
205 205
206 206 regex_tags = re.compile(r'^[\w\s\d]+$', re.UNICODE)
207 207
208 208 tags = forms.CharField(
209 209 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
210 210 max_length=100, label=_('Tags'), required=True)
211 211
212 212 def clean_tags(self):
213 213 tags = self.cleaned_data['tags'].strip()
214 214
215 215 if not tags or not self.regex_tags.match(tags):
216 216 raise forms.ValidationError(
217 217 _('Inappropriate characters in tags.'))
218 218
219 tag_models = []
220 required_tag_exists = False
221 for tag in tags.split():
222 tag_model = Tag.objects.filter(name=tag.strip().lower(),
223 required=True)
224 if tag_model.exists():
225 required_tag_exists = True
226
227 if not required_tag_exists:
228 raise forms.ValidationError(_('Need at least 1 required tag.'))
229
219 230 return tags
220 231
221 232 def clean(self):
222 233 cleaned_data = super(ThreadForm, self).clean()
223 234
224 235 return cleaned_data
225 236
226 237
227 238 class SettingsForm(NeboardForm):
228 239
229 240 theme = forms.ChoiceField(choices=settings.THEMES,
230 241 label=_('Theme'))
231 242
232 243
233 244 class AddTagForm(NeboardForm):
234 245
235 246 tag = forms.CharField(max_length=TAG_MAX_LENGTH, label=LABEL_TAG)
236 247 method = forms.CharField(widget=forms.HiddenInput(), initial='add_tag')
237 248
238 249 def clean_tag(self):
239 250 tag = self.cleaned_data['tag']
240 251
241 252 regex_tag = re.compile(REGEX_TAG, re.UNICODE)
242 253 if not regex_tag.match(tag):
243 254 raise forms.ValidationError(_('Inappropriate characters in tags.'))
244 255
245 256 return tag
246 257
247 258 def clean(self):
248 259 cleaned_data = super(AddTagForm, self).clean()
249 260
250 261 return cleaned_data
251 262
252 263
253 264 class SearchForm(NeboardForm):
254 265 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
255 266
256 267
257 268 class LoginForm(NeboardForm):
258 269
259 270 password = forms.CharField()
260 271
261 272 session = None
262 273
263 274 def clean_password(self):
264 275 password = self.cleaned_data['password']
265 276 if board_settings.MASTER_PASSWORD != password:
266 277 raise forms.ValidationError(_('Invalid master password'))
267 278
268 279 return password
269 280
270 281 def _validate_login_speed(self):
271 282 can_post = True
272 283
273 284 if LAST_LOGIN_TIME in self.session:
274 285 now = time.time()
275 286 last_login_time = self.session[LAST_LOGIN_TIME]
276 287
277 288 current_delay = int(now - last_login_time)
278 289
279 290 if current_delay < board_settings.LOGIN_TIMEOUT:
280 291 error_message = _('Wait %s minutes after last login') % str(
281 292 (board_settings.LOGIN_TIMEOUT - current_delay) / 60)
282 293 self._errors['password'] = self.error_class([error_message])
283 294
284 295 can_post = False
285 296
286 297 if can_post:
287 298 self.session[LAST_LOGIN_TIME] = time.time()
288 299
289 300 def clean(self):
290 301 self._validate_login_speed()
291 302
292 303 cleaned_data = super(LoginForm, self).clean()
293 304
294 305 return cleaned_data
1 NO CONTENT: modified file, binary diff hidden
@@ -1,365 +1,370 b''
1 1 # SOME DESCRIPTIVE TITLE.
2 2 # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 3 # This file is distributed under the same license as the PACKAGE package.
4 4 # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
5 5 #
6 6 msgid ""
7 7 msgstr ""
8 8 "Project-Id-Version: PACKAGE VERSION\n"
9 9 "Report-Msgid-Bugs-To: \n"
10 "POT-Creation-Date: 2014-08-19 15:51+0300\n"
10 "POT-Creation-Date: 2015-01-08 16:36+0200\n"
11 11 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
12 12 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
13 13 "Language-Team: LANGUAGE <LL@li.org>\n"
14 14 "Language: ru\n"
15 15 "MIME-Version: 1.0\n"
16 16 "Content-Type: text/plain; charset=UTF-8\n"
17 17 "Content-Transfer-Encoding: 8bit\n"
18 18 "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
19 19 "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
20 20
21 21 #: authors.py:9
22 22 msgid "author"
23 23 msgstr "автор"
24 24
25 25 #: authors.py:10
26 26 msgid "developer"
27 27 msgstr "разработчик"
28 28
29 29 #: authors.py:11
30 30 msgid "javascript developer"
31 31 msgstr "разработчик javascript"
32 32
33 33 #: authors.py:12
34 34 msgid "designer"
35 35 msgstr "дизайнер"
36 36
37 37 #: forms.py:22
38 38 msgid "Type message here. Use formatting panel for more advanced usage."
39 39 msgstr ""
40 40 "Вводите сообщение сюда. Используйте панель для более сложного форматирования."
41 41
42 42 #: forms.py:23
43 43 msgid "tag1 several_words_tag"
44 msgstr "тег1 тег_из_нескольких_слов"
44 msgstr "метка1 метка_из_нескольких_слов"
45 45
46 46 #: forms.py:25
47 47 msgid "Such image was already posted"
48 48 msgstr "Такое изображение уже было загружено"
49 49
50 50 #: forms.py:27
51 51 msgid "Title"
52 52 msgstr "Заголовок"
53 53
54 54 #: forms.py:28
55 55 msgid "Text"
56 56 msgstr "Текст"
57 57
58 58 #: forms.py:29
59 59 msgid "Tag"
60 msgstr "Тег"
60 msgstr "Метка"
61 61
62 #: forms.py:30 templates/boards/base.html:36 templates/search/search.html:9
62 #: forms.py:30 templates/boards/base.html:38 templates/search/search.html:9
63 63 #: templates/search/search.html.py:13
64 64 msgid "Search"
65 65 msgstr "Поиск"
66 66
67 67 #: forms.py:107
68 68 msgid "Image"
69 69 msgstr "Изображение"
70 70
71 71 #: forms.py:112
72 72 msgid "e-mail"
73 73 msgstr ""
74 74
75 75 #: forms.py:123
76 76 #, python-format
77 77 msgid "Title must have less than %s characters"
78 78 msgstr "Заголовок должен иметь меньше %s символов"
79 79
80 80 #: forms.py:132
81 81 #, python-format
82 82 msgid "Text must have less than %s characters"
83 83 msgstr "Текст должен быть короче %s символов"
84 84
85 85 #: forms.py:143
86 86 #, python-format
87 87 msgid "Image must be less than %s bytes"
88 88 msgstr "Изображение должно быть менее %s байт"
89 89
90 90 #: forms.py:178
91 91 msgid "Either text or image must be entered."
92 92 msgstr "Текст или картинка должны быть введены."
93 93
94 #: forms.py:198
94 #: forms.py:194
95 95 #, python-format
96 96 msgid "Wait %s seconds after last posting"
97 97 msgstr "Подождите %s секунд после последнего постинга"
98 98
99 #: forms.py:214 templates/boards/tags.html:7 templates/boards/rss/post.html:10
99 #: forms.py:210 templates/boards/rss/post.html:10 templates/boards/tags.html:7
100 100 msgid "Tags"
101 msgstr "Теги"
101 msgstr "Метки"
102 102
103 #: forms.py:221 forms.py:247
103 #: forms.py:217 forms.py:254
104 104 msgid "Inappropriate characters in tags."
105 msgstr "Недопустимые символы в тегах."
105 msgstr "Недопустимые символы в метках."
106 106
107 #: forms.py:234
107 #: forms.py:228
108 msgid "Need at least 1 required tag."
109 msgstr "Нужна хотя бы 1 обязательная метка."
110
111 #: forms.py:241
108 112 msgid "Theme"
109 113 msgstr "Тема"
110 114
111 #: forms.py:270
115 #: forms.py:277
112 116 msgid "Invalid master password"
113 117 msgstr "Неверный мастер-пароль"
114 118
115 #: forms.py:284
119 #: forms.py:291
116 120 #, python-format
117 121 msgid "Wait %s minutes after last login"
118 122 msgstr "Подождите %s минут после последнего входа"
119 123
120 124 #: templates/boards/404.html:6
121 125 msgid "Not found"
122 126 msgstr "Не найдено"
123 127
124 128 #: templates/boards/404.html:12
125 129 msgid "This page does not exist"
126 130 msgstr "Этой страницы не существует"
127 131
128 132 #: templates/boards/authors.html:6 templates/boards/authors.html.py:12
129 133 msgid "Authors"
130 134 msgstr "Авторы"
131 135
132 136 #: templates/boards/authors.html:26
133 137 msgid "Distributed under the"
134 138 msgstr "Распространяется под"
135 139
136 140 #: templates/boards/authors.html:28
137 141 msgid "license"
138 142 msgstr "лицензией"
139 143
140 144 #: templates/boards/authors.html:30
141 145 msgid "Repository"
142 146 msgstr "Репозиторий"
143 147
144 #: templates/boards/base.html:12
148 #: templates/boards/base.html:13
145 149 msgid "Feed"
146 150 msgstr "Лента"
147 151
148 #: templates/boards/base.html:29
152 #: templates/boards/base.html:30
149 153 msgid "All threads"
150 154 msgstr "Все темы"
151 155
152 #: templates/boards/base.html:34
156 #: templates/boards/base.html:36
153 157 msgid "Tag management"
154 msgstr "Управление тегами"
158 msgstr "Управление метками"
155 159
156 #: templates/boards/base.html:37 templates/boards/settings.html:7
160 #: templates/boards/base.html:39 templates/boards/settings.html:7
157 161 msgid "Settings"
158 162 msgstr "Настройки"
159 163
160 #: templates/boards/base.html:50
164 #: templates/boards/base.html:52
161 165 msgid "Admin"
162 166 msgstr ""
163 167
164 #: templates/boards/base.html:52
168 #: templates/boards/base.html:54
165 169 #, python-format
166 170 msgid "Speed: %(ppd)s posts per day"
167 171 msgstr "Скорость: %(ppd)s сообщений в день"
168 172
169 #: templates/boards/base.html:54
173 #: templates/boards/base.html:56
170 174 msgid "Up"
171 175 msgstr "Вверх"
172 176
173 177 #: templates/boards/login.html:6 templates/boards/login.html.py:16
174 178 msgid "Login"
175 179 msgstr "Вход"
176 180
177 181 #: templates/boards/login.html:19
178 182 msgid "Insert your user id above"
179 183 msgstr "Вставьте свой ID пользователя выше"
180 184
181 #: templates/boards/post.html:21 templates/boards/staticpages/help.html:17
185 #: templates/boards/post.html:19 templates/boards/staticpages/help.html:17
182 186 msgid "Quote"
183 187 msgstr "Цитата"
184 188
185 #: templates/boards/post.html:31
189 #: templates/boards/post.html:27
186 190 msgid "Open"
187 191 msgstr "Открыть"
188 192
189 #: templates/boards/post.html:33
193 #: templates/boards/post.html:29
190 194 msgid "Reply"
191 195 msgstr "Ответ"
192 196
193 #: templates/boards/post.html:40
197 #: templates/boards/post.html:36
194 198 msgid "Edit"
195 199 msgstr "Изменить"
196 200
197 #: templates/boards/post.html:42
198 msgid "Delete"
199 msgstr "Удалить"
201 #: templates/boards/post.html:39
202 msgid "Edit thread"
203 msgstr "Изменить тему"
200 204
201 #: templates/boards/post.html:45
202 msgid "Ban IP"
203 msgstr "Заблокировать IP"
204
205 #: templates/boards/post.html:76
205 #: templates/boards/post.html:71
206 206 msgid "Replies"
207 207 msgstr "Ответы"
208 208
209 #: templates/boards/post.html:86 templates/boards/thread.html:88
209 #: templates/boards/post.html:79 templates/boards/thread.html:89
210 210 #: templates/boards/thread_gallery.html:59
211 211 msgid "messages"
212 212 msgstr "сообщений"
213 213
214 #: templates/boards/post.html:87 templates/boards/thread.html:89
214 #: templates/boards/post.html:80 templates/boards/thread.html:90
215 215 #: templates/boards/thread_gallery.html:60
216 216 msgid "images"
217 217 msgstr "изображений"
218 218
219 219 #: templates/boards/post_admin.html:19
220 220 msgid "Tags:"
221 msgstr "Теги:"
221 msgstr "Метки:"
222 222
223 223 #: templates/boards/post_admin.html:30
224 224 msgid "Add tag"
225 msgstr "Добавить тег"
225 msgstr "Добавить метку"
226 226
227 227 #: templates/boards/posting_general.html:56
228 228 msgid "Show tag"
229 msgstr "Показывать тег"
229 msgstr "Показывать метку"
230 230
231 231 #: templates/boards/posting_general.html:60
232 232 msgid "Hide tag"
233 msgstr "Скрывать тег"
233 msgstr "Скрывать метку"
234 234
235 #: templates/boards/posting_general.html:79 templates/search/search.html:22
235 #: templates/boards/posting_general.html:66
236 msgid "Edit tag"
237 msgstr "Изменить метку"
238
239 #: templates/boards/posting_general.html:82 templates/search/search.html:22
236 240 msgid "Previous page"
237 241 msgstr "Предыдущая страница"
238 242
239 #: templates/boards/posting_general.html:94
243 #: templates/boards/posting_general.html:97
240 244 #, python-format
241 245 msgid "Skipped %(count)s replies. Open thread to see all replies."
242 246 msgstr "Пропущено %(count)s ответов. Откройте тред, чтобы увидеть все ответы."
243 247
244 #: templates/boards/posting_general.html:121 templates/search/search.html:33
248 #: templates/boards/posting_general.html:124 templates/search/search.html:33
245 249 msgid "Next page"
246 250 msgstr "Следующая страница"
247 251
248 #: templates/boards/posting_general.html:126
252 #: templates/boards/posting_general.html:129
249 253 msgid "No threads exist. Create the first one!"
250 254 msgstr "Нет тем. Создайте первую!"
251 255
252 #: templates/boards/posting_general.html:132
256 #: templates/boards/posting_general.html:135
253 257 msgid "Create new thread"
254 258 msgstr "Создать новую тему"
255 259
256 #: templates/boards/posting_general.html:137 templates/boards/preview.html:16
257 #: templates/boards/thread.html:58
260 #: templates/boards/posting_general.html:140 templates/boards/preview.html:16
261 #: templates/boards/thread.html:54
258 262 msgid "Post"
259 263 msgstr "Отправить"
260 264
261 #: templates/boards/posting_general.html:142
265 #: templates/boards/posting_general.html:145
262 266 msgid "Tags must be delimited by spaces. Text or image is required."
263 267 msgstr ""
264 "Теги должны быть разделены пробелами. Текст или изображение обязательны."
268 "Метки должны быть разделены пробелами. Текст или изображение обязательны."
265 269
266 #: templates/boards/posting_general.html:145 templates/boards/thread.html:66
270 #: templates/boards/posting_general.html:148 templates/boards/thread.html:62
267 271 msgid "Text syntax"
268 272 msgstr "Синтаксис текста"
269 273
270 #: templates/boards/posting_general.html:157
274 #: templates/boards/posting_general.html:160
271 275 msgid "Pages:"
272 276 msgstr "Страницы: "
273 277
274 278 #: templates/boards/preview.html:6 templates/boards/staticpages/help.html:19
275 279 msgid "Preview"
276 280 msgstr "Предпросмотр"
277 281
282 #: templates/boards/rss/post.html:5
283 msgid "Post image"
284 msgstr "Изображение сообщения"
285
278 286 #: templates/boards/settings.html:15
279 287 msgid "You are moderator."
280 288 msgstr "Вы модератор."
281 289
282 290 #: templates/boards/settings.html:19
283 291 msgid "Hidden tags:"
284 msgstr "Скрытые теги:"
292 msgstr "Скрытые метки:"
285 293
286 294 #: templates/boards/settings.html:26
287 295 msgid "No hidden tags."
288 msgstr "Нет скрытых тегов."
296 msgstr "Нет скрытых меток."
289 297
290 298 #: templates/boards/settings.html:35
291 299 msgid "Save"
292 300 msgstr "Сохранить"
293 301
294 #: templates/boards/tags.html:22
295 msgid "No tags found."
296 msgstr "Теги не найдены."
297
298 #: templates/boards/thread.html:20 templates/boards/thread_gallery.html:19
299 msgid "Normal mode"
300 msgstr "Нормальный режим"
301
302 #: templates/boards/thread.html:21 templates/boards/thread_gallery.html:20
303 msgid "Gallery mode"
304 msgstr "Режим галереи"
305
306 #: templates/boards/thread.html:29
307 msgid "posts to bumplimit"
308 msgstr "сообщений до бамплимита"
309
310 #: templates/boards/thread.html:50
311 msgid "Reply to thread"
312 msgstr "Ответить в тему"
313
314 #: templates/boards/thread.html:63
315 msgid "Switch mode"
316 msgstr "Переключить режим"
317
318 #: templates/boards/thread.html:90 templates/boards/thread_gallery.html:61
319 msgid "Last update: "
320 msgstr "Последнее обновление: "
321
322 #: templates/boards/rss/post.html:5
323 msgid "Post image"
324 msgstr "Изображение сообщения"
325
326 302 #: templates/boards/staticpages/banned.html:6
327 303 msgid "Banned"
328 304 msgstr "Заблокирован"
329 305
330 306 #: templates/boards/staticpages/banned.html:11
331 307 msgid "Your IP address has been banned. Contact the administrator"
332 308 msgstr "Ваш IP адрес был заблокирован. Свяжитесь с администратором"
333 309
334 310 #: templates/boards/staticpages/help.html:6
335 311 #: templates/boards/staticpages/help.html:10
336 312 msgid "Syntax"
337 313 msgstr "Синтаксис"
338 314
339 315 #: templates/boards/staticpages/help.html:11
340 316 msgid "Italic text"
341 317 msgstr "Курсивный текст"
342 318
343 319 #: templates/boards/staticpages/help.html:12
344 320 msgid "Bold text"
345 321 msgstr "Полужирный текст"
346 322
347 323 #: templates/boards/staticpages/help.html:13
348 324 msgid "Spoiler"
349 325 msgstr "Спойлер"
350 326
351 327 #: templates/boards/staticpages/help.html:14
352 328 msgid "Link to a post"
353 329 msgstr "Ссылка на сообщение"
354 330
355 331 #: templates/boards/staticpages/help.html:15
356 332 msgid "Strikethrough text"
357 333 msgstr "Зачеркнутый текст"
358 334
359 335 #: templates/boards/staticpages/help.html:16
360 336 msgid "Comment"
361 337 msgstr "Комментарий"
362 338
363 339 #: templates/boards/staticpages/help.html:19
364 340 msgid "You can try pasting the text and previewing the result here:"
365 341 msgstr "Вы можете попробовать вставить текст и проверить результат здесь:"
342
343 #: templates/boards/tags.html:23
344 msgid "No tags found."
345 msgstr "Метки не найдены."
346
347 #: templates/boards/thread.html:19 templates/boards/thread_gallery.html:19
348 msgid "Normal mode"
349 msgstr "Нормальный режим"
350
351 #: templates/boards/thread.html:20 templates/boards/thread_gallery.html:20
352 msgid "Gallery mode"
353 msgstr "Режим галереи"
354
355 #: templates/boards/thread.html:28
356 msgid "posts to bumplimit"
357 msgstr "сообщений до бамплимита"
358
359 #: templates/boards/thread.html:46
360 msgid "Reply to thread"
361 msgstr "Ответить в тему"
362
363 #: templates/boards/thread.html:59
364 msgid "Switch mode"
365 msgstr "Переключить режим"
366
367 #: templates/boards/thread.html:91 templates/boards/thread_gallery.html:61
368 msgid "Last update: "
369 msgstr "Последнее обновление: "
370
@@ -1,201 +1,202 b''
1 1 # coding=utf-8
2 2
3 3 import re
4 4 import bbcode
5 5
6 6 import boards
7 7
8 8
9 9 __author__ = 'neko259'
10 10
11 11
12 12 REFLINK_PATTERN = re.compile(r'^\d+$')
13 13 GLOBAL_REFLINK_PATTERN = re.compile(r'^(\w+)::([^:]+)::(\d+)$')
14 14 MULTI_NEWLINES_PATTERN = re.compile(r'(\r?\n){2,}')
15 15 ONE_NEWLINE = '\n'
16 16
17 17
18 18 class TextFormatter():
19 19 """
20 20 An interface for formatter that can be used in the text format panel
21 21 """
22 22
23 23 def __init__(self):
24 24 pass
25 25
26 26 name = ''
27 27
28 28 # Left and right tags for the button preview
29 29 preview_left = ''
30 30 preview_right = ''
31 31
32 32 # Left and right characters for the textarea input
33 33 format_left = ''
34 34 format_right = ''
35 35
36 36
37 37 class AutolinkPattern():
38 38 def handleMatch(self, m):
39 39 link_element = etree.Element('a')
40 40 href = m.group(2)
41 41 link_element.set('href', href)
42 42 link_element.text = href
43 43
44 44 return link_element
45 45
46 46
47 47 class QuotePattern(TextFormatter):
48 48 name = 'q'
49 49 preview_left = '<span class="multiquote">'
50 50 preview_right = '</span>'
51 51
52 52 format_left = '[quote]'
53 53 format_right = '[/quote]'
54 54
55 55
56 56 class SpoilerPattern(TextFormatter):
57 57 name = 'spoiler'
58 58 preview_left = '<span class="spoiler">'
59 59 preview_right = '</span>'
60 60
61 61 format_left = '[spoiler]'
62 62 format_right = '[/spoiler]'
63 63
64 64 def handleMatch(self, m):
65 65 quote_element = etree.Element('span')
66 66 quote_element.set('class', 'spoiler')
67 67 quote_element.text = m.group(2)
68 68
69 69 return quote_element
70 70
71 71
72 72 class CommentPattern(TextFormatter):
73 73 name = ''
74 74 preview_left = '<span class="comment">// '
75 75 preview_right = '</span>'
76 76
77 77 format_left = '[comment]'
78 78 format_right = '[/comment]'
79 79
80 80
81 81 # TODO Use <s> tag here
82 82 class StrikeThroughPattern(TextFormatter):
83 83 name = 's'
84 84 preview_left = '<span class="strikethrough">'
85 85 preview_right = '</span>'
86 86
87 87 format_left = '[s]'
88 88 format_right = '[/s]'
89 89
90 90
91 91 class ItalicPattern(TextFormatter):
92 92 name = 'i'
93 93 preview_left = '<i>'
94 94 preview_right = '</i>'
95 95
96 96 format_left = '[i]'
97 97 format_right = '[/i]'
98 98
99 99
100 100 class BoldPattern(TextFormatter):
101 101 name = 'b'
102 102 preview_left = '<b>'
103 103 preview_right = '</b>'
104 104
105 105 format_left = '[b]'
106 106 format_right = '[/b]'
107 107
108 108
109 109 class CodePattern(TextFormatter):
110 110 name = 'code'
111 111 preview_left = '<code>'
112 112 preview_right = '</code>'
113 113
114 114 format_left = '[code]'
115 115 format_right = '[/code]'
116 116
117 117
118 118 def render_reflink(tag_name, value, options, parent, context):
119 119 post_id = None
120 120
121 121 matches = REFLINK_PATTERN.findall(value)
122 122 if matches:
123 123 post_id = int(matches[0][0])
124 124 else:
125 125 match = GLOBAL_REFLINK_PATTERN.match(value)
126 126 if match:
127 127 key_type = match.group(1)
128 128 key = match.group(2)
129 129 local_id = match.group(3)
130 130
131 131 try:
132 132 global_id = boards.models.GlobalId.objects.get(key_type=key_type,
133 133 key=key, local_id=local_id)
134 134 for post in boards.models.Post.objects.filter(global_id=global_id).only('id'):
135 135 post_id = post.id
136 136 except boards.models.GlobalId.DoesNotExist:
137 137 pass
138 138
139 139 if not post_id:
140 140 return value
141 141
142 142 posts = boards.models.Post.objects.filter(id=post_id)
143 143 if posts.exists():
144 144 post = posts[0]
145 145
146 146 return '<a href="%s">&gt;&gt;%s</a>' % (post.get_url(), post_id)
147 147 else:
148 148 return '>>%s' % value
149 149
150 150
151 151 def render_quote(tag_name, value, options, parent, context):
152 152 source = ''
153 153 if 'source' in options:
154 154 source = options['source']
155 155
156 156 result = ''
157 157 if source:
158 158 result = '<div class="multiquote"><div class="quote-header">%s</div><div class="quote-text">%s</div></div>' % (source, value)
159 159 else:
160 160 result = '<div class="multiquote"><div class="quote-text">%s</div></div>' % value
161 161
162 162 return result
163 163
164 164
165 165 def preparse_text(text):
166 166 """
167 167 Performs manual parsing before the bbcode parser is used.
168 168 """
169 169
170 170 return MULTI_NEWLINES_PATTERN.sub(ONE_NEWLINE, text)
171 171
172 172
173 173 def bbcode_extended(markup):
174 174 # The newline hack is added because br's margin does not work in all
175 175 # browsers except firefox, when the div's does.
176 176 parser = bbcode.Parser(newline='<div class="br"></div>')
177 177 parser.add_formatter('post', render_reflink, strip=True)
178 178 parser.add_formatter('quote', render_quote, strip=True)
179 179 parser.add_simple_formatter('comment',
180 u'<span class="comment">//%(value)s</span>')
180 '<span class="comment">//%(value)s</span>')
181 181 parser.add_simple_formatter('spoiler',
182 u'<span class="spoiler">%(value)s</span>')
182 '<span class="spoiler">%(value)s</span>')
183 183 # TODO Use <s> here
184 184 parser.add_simple_formatter('s',
185 u'<span class="strikethrough">%(value)s</span>')
185 '<span class="strikethrough">%(value)s</span>')
186 186 # TODO Why not use built-in tag?
187 187 parser.add_simple_formatter('code',
188 u'<pre><code>%(value)s</pre></code>', render_embedded=False)
188 '<pre><code>%(value)s</pre></code>',
189 render_embedded=False)
189 190
190 191 text = preparse_text(markup)
191 192 return parser.format(text)
192 193
193 194 formatters = [
194 195 QuotePattern,
195 196 SpoilerPattern,
196 197 ItalicPattern,
197 198 BoldPattern,
198 199 CommentPattern,
199 200 StrikeThroughPattern,
200 201 CodePattern,
201 202 ]
@@ -1,46 +1,28 b''
1 1 from django.shortcuts import redirect
2 2 from boards import utils
3 3 from boards.models import Ban
4 from django.utils.html import strip_spaces_between_tags
5 from django.conf import settings
6 from boards.views.banned import BannedView
7 4
8 5 RESPONSE_CONTENT_TYPE = 'Content-Type'
9 6
10 7 TYPE_HTML = 'text/html'
11 8
12 9
13 10 class BanMiddleware:
14 11 """
15 12 This is run before showing the thread. Banned users don't need to see
16 13 anything
17 14 """
18 15
19 16 def __init__(self):
20 17 pass
21 18
22 19 def process_view(self, request, view_func, view_args, view_kwargs):
23 20
24 if view_func != BannedView.as_view:
21 if request.path != '/banned/':
25 22 ip = utils.get_client_ip(request)
26 23 bans = Ban.objects.filter(ip=ip)
27 24
28 25 if bans.exists():
29 26 ban = bans[0]
30 27 if not ban.can_read:
31 28 return redirect('banned')
32
33
34 class MinifyHTMLMiddleware(object):
35 def process_response(self, request, response):
36 try:
37 compress_html = settings.COMPRESS_HTML
38 except AttributeError:
39 compress_html = False
40
41 if RESPONSE_CONTENT_TYPE in response\
42 and TYPE_HTML in response[RESPONSE_CONTENT_TYPE]\
43 and compress_html:
44 response.content = strip_spaces_between_tags(
45 response.content.strip())
46 return response No newline at end of file
@@ -1,95 +1,113 b''
1 1 # -*- coding: utf-8 -*-
2 import datetime
3 from south.db import db
4 from south.v2 import SchemaMigration
5 from django.db import models
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5 import boards.models.image
6 import boards.models.base
7 import boards.thumbs
6 8
7 9
8 class Migration(SchemaMigration):
9
10 def forwards(self, orm):
11 # Adding model 'Tag'
12 db.create_table(u'boards_tag', (
13 (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
14 ('name', self.gf('django.db.models.fields.CharField')(max_length=100)),
15 ))
16 db.send_create_signal(u'boards', ['Tag'])
10 class Migration(migrations.Migration):
17 11
18 # Adding model 'Post'
19 db.create_table(u'boards_post', (
20 (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
21 ('title', self.gf('django.db.models.fields.CharField')(max_length=50)),
22 ('pub_time', self.gf('django.db.models.fields.DateTimeField')()),
23 ('text', self.gf('markupfield.fields.MarkupField')(rendered_field=True)),
24 ('text_markup_type', self.gf('django.db.models.fields.CharField')(default='markdown', max_length=30)),
25 ('image', self.gf('boards.thumbs.ImageWithThumbsField')(max_length=100, blank=True)),
26 ('poster_ip', self.gf('django.db.models.fields.IPAddressField')(max_length=15)),
27 ('_text_rendered', self.gf('django.db.models.fields.TextField')()),
28 ('poster_user_agent', self.gf('django.db.models.fields.TextField')()),
29 ('parent', self.gf('django.db.models.fields.BigIntegerField')()),
30 ('last_edit_time', self.gf('django.db.models.fields.DateTimeField')()),
31 ))
32 db.send_create_signal(u'boards', ['Post'])
33
34 # Adding M2M table for field tags on 'Post'
35 m2m_table_name = db.shorten_name(u'boards_post_tags')
36 db.create_table(m2m_table_name, (
37 ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
38 ('post', models.ForeignKey(orm[u'boards.post'], null=False)),
39 ('tag', models.ForeignKey(orm[u'boards.tag'], null=False))
40 ))
41 db.create_unique(m2m_table_name, ['post_id', 'tag_id'])
42
43 # Adding model 'Admin'
44 db.create_table(u'boards_admin', (
45 (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
46 ('name', self.gf('django.db.models.fields.CharField')(max_length=100)),
47 ('password', self.gf('django.db.models.fields.CharField')(max_length=100)),
48 ))
49 db.send_create_signal(u'boards', ['Admin'])
50
12 dependencies = [
13 ]
51 14
52 def backwards(self, orm):
53 # Deleting model 'Tag'
54 db.delete_table(u'boards_tag')
55
56 # Deleting model 'Post'
57 db.delete_table(u'boards_post')
58
59 # Removing M2M table for field tags on 'Post'
60 db.delete_table(db.shorten_name(u'boards_post_tags'))
61
62 # Deleting model 'Admin'
63 db.delete_table(u'boards_admin')
64
65
66 models = {
67 u'boards.admin': {
68 'Meta': {'object_name': 'Admin'},
69 u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
70 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
71 'password': ('django.db.models.fields.CharField', [], {'max_length': '100'})
72 },
73 u'boards.post': {
74 'Meta': {'object_name': 'Post'},
75 '_text_rendered': ('django.db.models.fields.TextField', [], {}),
76 u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
77 'image': ('boards.thumbs.ImageWithThumbsField', [], {'max_length': '100', 'blank': 'True'}),
78 'last_edit_time': ('django.db.models.fields.DateTimeField', [], {}),
79 'parent': ('django.db.models.fields.BigIntegerField', [], {}),
80 'poster_ip': ('django.db.models.fields.IPAddressField', [], {'max_length': '15'}),
81 'poster_user_agent': ('django.db.models.fields.TextField', [], {}),
82 'pub_time': ('django.db.models.fields.DateTimeField', [], {}),
83 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['boards.Tag']", 'symmetrical': 'False'}),
84 'text': ('markupfield.fields.MarkupField', [], {'rendered_field': 'True'}),
85 'text_markup_type': ('django.db.models.fields.CharField', [], {'default': "'markdown'", 'max_length': '30'}),
86 'title': ('django.db.models.fields.CharField', [], {'max_length': '50'})
87 },
88 u'boards.tag': {
89 'Meta': {'object_name': 'Tag'},
90 u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
91 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
92 }
93 }
94
95 complete_apps = ['boards'] No newline at end of file
15 operations = [
16 migrations.CreateModel(
17 name='Ban',
18 fields=[
19 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
20 ('ip', models.GenericIPAddressField()),
21 ('reason', models.CharField(max_length=200, default='Auto')),
22 ('can_read', models.BooleanField(default=True)),
23 ],
24 options={
25 },
26 bases=(models.Model,),
27 ),
28 migrations.CreateModel(
29 name='Post',
30 fields=[
31 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
32 ('title', models.CharField(max_length=200)),
33 ('pub_time', models.DateTimeField()),
34 ('text', models.TextField(null=True, blank=True)),
35 ('text_markup_type', models.CharField(choices=[('', '--'), ('bbcode', 'bbcode')], max_length=30, default='bbcode')),
36 ('poster_ip', models.GenericIPAddressField()),
37 ('_text_rendered', models.TextField(editable=False)),
38 ('poster_user_agent', models.TextField()),
39 ('last_edit_time', models.DateTimeField()),
40 ('refmap', models.TextField(null=True, blank=True)),
41 ],
42 options={
43 'ordering': ('id',),
44 },
45 bases=(models.Model, boards.models.base.Viewable),
46 ),
47 migrations.CreateModel(
48 name='PostImage',
49 fields=[
50 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
51 ('width', models.IntegerField(default=0)),
52 ('height', models.IntegerField(default=0)),
53 ('pre_width', models.IntegerField(default=0)),
54 ('pre_height', models.IntegerField(default=0)),
55 ('image', boards.thumbs.ImageWithThumbsField(height_field='height', width_field='width', upload_to=boards.models.image.PostImage._update_image_filename, blank=True)),
56 ('hash', models.CharField(max_length=36)),
57 ],
58 options={
59 'ordering': ('id',),
60 },
61 bases=(models.Model,),
62 ),
63 migrations.CreateModel(
64 name='Tag',
65 fields=[
66 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
67 ('name', models.CharField(db_index=True, max_length=100)),
68 ],
69 options={
70 'ordering': ('name',),
71 },
72 bases=(models.Model, boards.models.base.Viewable),
73 ),
74 migrations.CreateModel(
75 name='Thread',
76 fields=[
77 ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
78 ('bump_time', models.DateTimeField()),
79 ('last_edit_time', models.DateTimeField()),
80 ('archived', models.BooleanField(default=False)),
81 ('bumpable', models.BooleanField(default=True)),
82 ('replies', models.ManyToManyField(null=True, related_name='tre+', to='boards.Post', blank=True)),
83 ('tags', models.ManyToManyField(to='boards.Tag')),
84 ],
85 options={
86 },
87 bases=(models.Model,),
88 ),
89 migrations.AddField(
90 model_name='tag',
91 name='threads',
92 field=models.ManyToManyField(null=True, related_name='tag+', to='boards.Thread', blank=True),
93 preserve_default=True,
94 ),
95 migrations.AddField(
96 model_name='post',
97 name='images',
98 field=models.ManyToManyField(null=True, db_index=True, related_name='ip+', to='boards.PostImage', blank=True),
99 preserve_default=True,
100 ),
101 migrations.AddField(
102 model_name='post',
103 name='referenced_posts',
104 field=models.ManyToManyField(null=True, db_index=True, related_name='rfp+', to='boards.Post', blank=True),
105 preserve_default=True,
106 ),
107 migrations.AddField(
108 model_name='post',
109 name='thread_new',
110 field=models.ForeignKey(null=True, default=None, to='boards.Thread'),
111 preserve_default=True,
112 ),
113 ]
@@ -1,10 +1,18 b''
1 1 __author__ = 'neko259'
2 2
3 3
4 4 class Viewable():
5 5 def __init__(self):
6 6 pass
7 7
8 8 def get_view(self, *args, **kwargs):
9 """Get an HTML view for a model"""
10 pass No newline at end of file
9 """
10 Gets an HTML view for a model
11 """
12 pass
13
14 def get_search_view(self, *args, **kwargs):
15 """
16 Gets an HTML view for search.
17 """
18 pass
@@ -1,62 +1,83 b''
1 1 import hashlib
2 2 import os
3 3 from random import random
4 4 import time
5 5 from django.db import models
6 6 from boards import thumbs
7 from boards.models.base import Viewable
7 8
8 9 __author__ = 'neko259'
9 10
10 11
11 12 IMAGE_THUMB_SIZE = (200, 150)
12 13 IMAGES_DIRECTORY = 'images/'
13 14 FILE_EXTENSION_DELIMITER = '.'
15 HASH_LENGTH = 36
16
17 CSS_CLASS_IMAGE = 'image'
18 CSS_CLASS_THUMB = 'thumb'
14 19
15 20
16 class PostImage(models.Model):
21 class PostImage(models.Model, Viewable):
17 22 class Meta:
18 23 app_label = 'boards'
19 24 ordering = ('id',)
20 25
21 26 def _update_image_filename(self, filename):
22 27 """
23 28 Gets unique image filename
24 29 """
25 30
26 31 path = IMAGES_DIRECTORY
27 32 new_name = str(int(time.mktime(time.gmtime())))
28 33 new_name += str(int(random() * 1000))
29 34 new_name += FILE_EXTENSION_DELIMITER
30 35 new_name += filename.split(FILE_EXTENSION_DELIMITER)[-1:][0]
31 36
32 37 return os.path.join(path, new_name)
33 38
34 39 width = models.IntegerField(default=0)
35 40 height = models.IntegerField(default=0)
36 41
37 42 pre_width = models.IntegerField(default=0)
38 43 pre_height = models.IntegerField(default=0)
39 44
40 45 image = thumbs.ImageWithThumbsField(upload_to=_update_image_filename,
41 46 blank=True, sizes=(IMAGE_THUMB_SIZE,),
42 47 width_field='width',
43 48 height_field='height',
44 49 preview_width_field='pre_width',
45 50 preview_height_field='pre_height')
46 hash = models.CharField(max_length=36)
51 hash = models.CharField(max_length=HASH_LENGTH)
47 52
48 53 def save(self, *args, **kwargs):
49 54 """
50 55 Saves the model and computes the image hash for deduplication purposes.
51 56 """
52 57
53 58 if not self.pk and self.image:
54 59 md5 = hashlib.md5()
55 60 for chunk in self.image.chunks():
56 61 md5.update(chunk)
57 62 self.hash = md5.hexdigest()
58 63 super(PostImage, self).save(*args, **kwargs)
59 64
60 65 def __str__(self):
61 66 return self.image.url
62 67
68 def get_view(self):
69 return '<div class="{}">' \
70 '<a class="{}" href="{}">' \
71 '<img' \
72 ' src="{}"' \
73 ' alt="{}"' \
74 ' width="{}"' \
75 ' height="{}"' \
76 ' data-width="{}"' \
77 ' data-height="{}" />' \
78 '</a>' \
79 '</div>'\
80 .format(CSS_CLASS_IMAGE, CSS_CLASS_THUMB, self.image.url,
81 self.image.url_200x150,
82 str(self.hash), str(self.pre_width),
83 str(self.pre_height), str(self.width), str(self.height))
@@ -1,518 +1,623 b''
1 1 from datetime import datetime, timedelta, date
2 2 from datetime import time as dtime
3 3 import logging
4 4 import re
5 5 import xml.etree.ElementTree as et
6 6
7 from adjacent import Client
7 8 from django.core.cache import cache
8 9 from django.core.urlresolvers import reverse
9 10 from django.db import models, transaction
11 from django.db.models import TextField
10 12 from django.template.loader import render_to_string
11 13 from django.utils import timezone
12 14
13 from markupfield.fields import MarkupField
14
15 15 from boards.models import PostImage, KeyPair, GlobalId, Signature
16 from boards import settings
17 from boards.mdx_neboard import bbcode_extended
18 from boards.models import PostImage
16 19 from boards.models.base import Viewable
17 20 from boards.models.thread import Thread
18 21 from boards import utils
22 from boards.utils import datetime_to_epoch
19 23
20 24 ENCODING_UNICODE = 'unicode'
21 25
26 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
27 WS_NOTIFICATION_TYPE = 'notification_type'
28
29 WS_CHANNEL_THREAD = "thread:"
30
22 31 APP_LABEL_BOARDS = 'boards'
23 32
24 33 CACHE_KEY_PPD = 'ppd'
25 34 CACHE_KEY_POST_URL = 'post_url'
26 35
27 36 POSTS_PER_DAY_RANGE = 7
28 37
29 38 BAN_REASON_AUTO = 'Auto'
30 39
31 40 IMAGE_THUMB_SIZE = (200, 150)
32 41
33 42 TITLE_MAX_LENGTH = 200
34 43
35 DEFAULT_MARKUP_TYPE = 'bbcode'
36
37 44 # TODO This should be removed
38 45 NO_IP = '0.0.0.0'
39 46
40 47 # TODO Real user agent should be saved instead of this
41 48 UNKNOWN_UA = ''
42 49
43 50 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
44 51 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
45 52
46 53 TAG_MODEL = 'model'
47 54 TAG_REQUEST = 'request'
48 55 TAG_RESPONSE = 'response'
49 56 TAG_ID = 'id'
50 57 TAG_STATUS = 'status'
51 58 TAG_MODELS = 'models'
52 59 TAG_TITLE = 'title'
53 60 TAG_TEXT = 'text'
54 61 TAG_THREAD = 'thread'
55 62 TAG_PUB_TIME = 'pub-time'
56 63 TAG_SIGNATURES = 'signatures'
57 64 TAG_SIGNATURE = 'signature'
58 65 TAG_CONTENT = 'content'
59 66 TAG_ATTACHMENTS = 'attachments'
60 67 TAG_ATTACHMENT = 'attachment'
61 68
62 69 TYPE_GET = 'get'
63 70
64 71 ATTR_VERSION = 'version'
65 72 ATTR_TYPE = 'type'
66 73 ATTR_NAME = 'name'
67 74 ATTR_VALUE = 'value'
68 75 ATTR_MIMETYPE = 'mimetype'
69 76
70 77 STATUS_SUCCESS = 'success'
71 78
72 logger = logging.getLogger(__name__)
79 PARAMETER_TRUNCATED = 'truncated'
80 PARAMETER_TAG = 'tag'
81 PARAMETER_OFFSET = 'offset'
82 PARAMETER_DIFF_TYPE = 'type'
83 PARAMETER_BUMPABLE = 'bumpable'
84 PARAMETER_THREAD = 'thread'
85 PARAMETER_IS_OPENING = 'is_opening'
86 PARAMETER_MODERATOR = 'moderator'
87 PARAMETER_POST = 'post'
88 PARAMETER_OP_ID = 'opening_post_id'
89 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
90
91 DIFF_TYPE_HTML = 'html'
92 DIFF_TYPE_JSON = 'json'
93
94 PREPARSE_PATTERNS = {
95 r'>>(\d+)': r'[post]\1[/post]', # Reflink ">>123"
96 r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text"
97 r'^//(.+)': r'[comment]\1[/comment]', # Comment "//text"
98 }
73 99
74 100
75 101 class PostManager(models.Manager):
76 def create_post(self, title, text, image=None, thread=None, ip=NO_IP,
77 tags=None):
102 @transaction.atomic
103 def create_post(self, title: str, text: str, image=None, thread=None,
104 ip=NO_IP, tags: list=None):
78 105 """
79 106 Creates new post
80 107 """
81 108
82 109 if not tags:
83 110 tags = []
84 111
85 112 posting_time = timezone.now()
86 113 if not thread:
87 114 thread = Thread.objects.create(bump_time=posting_time,
88 115 last_edit_time=posting_time)
89 116 new_thread = True
90 117 else:
91 thread.bump()
92 thread.last_edit_time = posting_time
93 thread.save()
94 118 new_thread = False
95 119
120 pre_text = self._preparse_text(text)
121
96 122 post = self.create(title=title,
97 text=text,
123 text=pre_text,
98 124 pub_time=posting_time,
99 125 thread_new=thread,
100 126 poster_ip=ip,
101 127 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
102 128 # last!
103 129 last_edit_time=posting_time)
104 130
105 131 post.set_global_id()
106 132
133 logger = logging.getLogger('boards.post.create')
134
135 logger.info('Created post {} by {}'.format(
136 post, post.poster_ip))
137
107 138 if image:
108 139 post_image = PostImage.objects.create(image=image)
109 140 post.images.add(post_image)
110 logger.info('Created image #%d for post #%d' % (post_image.id,
111 post.id))
141 logger.info('Created image #{} for post #{}'.format(
142 post_image.id, post.id))
112 143
113 144 thread.replies.add(post)
114 145 list(map(thread.add_tag, tags))
115 146
116 147 if new_thread:
117 148 Thread.objects.process_oldest_threads()
118 self.connect_replies(post)
149 else:
150 thread.bump()
151 thread.last_edit_time = posting_time
152 thread.save()
119 153
120 logger.info('Created post #%d with title %s'
121 % (post.id, post.get_title()))
154 self.connect_replies(post)
122 155
123 156 return post
124 157
125 def delete_post(self, post):
126 """
127 Deletes post and update or delete its thread
128 """
129
130 post_id = post.id
131
132 thread = post.get_thread()
133
134 if post.is_opening():
135 thread.delete()
136 else:
137 thread.last_edit_time = timezone.now()
138 thread.save()
139
140 post.delete()
141
142 logger.info('Deleted post #%d (%s)' % (post_id, post.get_title()))
143
144 158 def delete_posts_by_ip(self, ip):
145 159 """
146 160 Deletes all posts of the author with same IP
147 161 """
148 162
149 163 posts = self.filter(poster_ip=ip)
150 164 for post in posts:
151 self.delete_post(post)
165 post.delete()
152 166
153 167 # TODO This can be moved into a post
154 168 def connect_replies(self, post):
155 169 """
156 170 Connects replies to a post to show them as a reflink map
157 171 """
158 172
159 for reply_number in post.get_replied_ids():
160 ref_post = self.filter(id=reply_number)
173 for reply_number in re.finditer(REGEX_REPLY, post.get_raw_text()):
174 post_id = reply_number.group(1)
175 ref_post = self.filter(id=post_id)
161 176 if ref_post.count() > 0:
162 177 referenced_post = ref_post[0]
163 178 referenced_post.referenced_posts.add(post)
164 179 referenced_post.last_edit_time = post.pub_time
165 180 referenced_post.build_refmap()
166 181 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
167 182
168 183 referenced_thread = referenced_post.get_thread()
169 184 referenced_thread.last_edit_time = post.pub_time
170 185 referenced_thread.save(update_fields=['last_edit_time'])
171 186
172 187 def get_posts_per_day(self):
173 188 """
174 189 Gets average count of posts per day for the last 7 days
175 190 """
176 191
177 192 day_end = date.today()
178 193 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
179 194
180 195 cache_key = CACHE_KEY_PPD + str(day_end)
181 196 ppd = cache.get(cache_key)
182 197 if ppd:
183 198 return ppd
184 199
185 200 day_time_start = timezone.make_aware(datetime.combine(
186 201 day_start, dtime()), timezone.get_current_timezone())
187 202 day_time_end = timezone.make_aware(datetime.combine(
188 203 day_end, dtime()), timezone.get_current_timezone())
189 204
190 205 posts_per_period = float(self.filter(
191 206 pub_time__lte=day_time_end,
192 207 pub_time__gte=day_time_start).count())
193 208
194 209 ppd = posts_per_period / POSTS_PER_DAY_RANGE
195 210
196 211 cache.set(cache_key, ppd)
197 212 return ppd
198 213
199 214 # TODO Make a separate sync facade?
200 215 def generate_response_get(self, model_list: list):
201 216 response = et.Element(TAG_RESPONSE)
202 217
203 218 status = et.SubElement(response, TAG_STATUS)
204 219 status.text = STATUS_SUCCESS
205 220
206 221 models = et.SubElement(response, TAG_MODELS)
207 222
208 223 for post in model_list:
209 224 model = et.SubElement(models, TAG_MODEL)
210 225 model.set(ATTR_NAME, 'post')
211 226
212 227 content_tag = et.SubElement(model, TAG_CONTENT)
213 228
214 229 tag_id = et.SubElement(content_tag, TAG_ID)
215 230 post.global_id.to_xml_element(tag_id)
216 231
217 232 title = et.SubElement(content_tag, TAG_TITLE)
218 233 title.text = post.title
219 234
220 235 text = et.SubElement(content_tag, TAG_TEXT)
221 236 # TODO Replace local links by global ones in the text
222 237 text.text = post.text.raw
223 238
224 239 if not post.is_opening():
225 240 thread = et.SubElement(content_tag, TAG_THREAD)
226 241 thread.text = str(post.get_thread().get_opening_post_id())
227 242 else:
228 243 # TODO Output tags here
229 244 pass
230 245
231 246 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
232 247 pub_time.text = str(post.get_pub_time_epoch())
233 248
234 249 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
235 250 post_signatures = post.signature.all()
236 251 if post_signatures:
237 252 signatures = post.signatures
238 253 else:
239 254 # TODO Maybe the signature can be computed only once after
240 255 # the post is added? Need to add some on_save signal queue
241 256 # and add this there.
242 257 key = KeyPair.objects.get(public_key=post.global_id.key)
243 258 signatures = [Signature(
244 259 key_type=key.key_type,
245 260 key=key.public_key,
246 261 signature=key.sign(et.tostring(model, ENCODING_UNICODE)),
247 262 )]
248 263 for signature in signatures:
249 264 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
250 265 signature_tag.set(ATTR_TYPE, signature.key_type)
251 266 signature_tag.set(ATTR_VALUE, signature.signature)
252 267
253 268 return et.tostring(response, ENCODING_UNICODE)
254 269
255 270 def parse_response_get(self, response_xml):
256 271 tag_root = et.fromstring(response_xml)
257 272 tag_status = tag_root[0]
258 273 if 'success' == tag_status.text:
259 274 tag_models = tag_root[1]
260 275 for tag_model in tag_models:
261 276 tag_content = tag_model[0]
262 277 tag_id = tag_content[1]
263 278 try:
264 279 GlobalId.from_xml_element(tag_id, existing=True)
265 280 # If this post already exists, just continue
266 281 # TODO Compare post content and update the post if necessary
267 282 pass
268 283 except GlobalId.DoesNotExist:
269 284 global_id = GlobalId.from_xml_element(tag_id)
270 285
271 286 title = tag_content.find(TAG_TITLE).text
272 287 text = tag_content.find(TAG_TEXT).text
273 288 # TODO Check that the replied posts are already present
274 289 # before adding new ones
275 290
276 291 # TODO Pub time, thread, tags
277 292
278 293 post = Post.objects.create(title=title, text=text)
279 294 else:
280 295 # TODO Throw an exception?
281 296 pass
282 297
298 def _preparse_text(self, text):
299 """
300 Preparses text to change patterns like '>>' to a proper bbcode
301 tags.
302 """
303
304 for key, value in PREPARSE_PATTERNS.items():
305 text = re.sub(key, value, text, flags=re.MULTILINE)
306
307 return text
308
283 309
284 310 class Post(models.Model, Viewable):
285 311 """A post is a message."""
286 312
287 313 objects = PostManager()
288 314
289 315 class Meta:
290 316 app_label = APP_LABEL_BOARDS
291 317 ordering = ('id',)
292 318
293 319 title = models.CharField(max_length=TITLE_MAX_LENGTH)
294 320 pub_time = models.DateTimeField()
295 text = MarkupField(default_markup_type=DEFAULT_MARKUP_TYPE,
296 escape_html=False)
321 text = TextField(blank=True, null=True)
322 _text_rendered = TextField(blank=True, null=True, editable=False)
297 323
298 324 images = models.ManyToManyField(PostImage, null=True, blank=True,
299 325 related_name='ip+', db_index=True)
300 326
301 327 poster_ip = models.GenericIPAddressField()
302 328 poster_user_agent = models.TextField()
303 329
304 330 thread_new = models.ForeignKey('Thread', null=True, default=None,
305 331 db_index=True)
306 332 last_edit_time = models.DateTimeField()
307 333
308 334 # Replies to the post
309 335 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
310 336 null=True,
311 337 blank=True, related_name='rfp+',
312 338 db_index=True)
313 339
314 340 # Replies map. This is built from the referenced posts list to speed up
315 341 # page loading (no need to get all the referenced posts from the database).
316 342 refmap = models.TextField(null=True, blank=True)
317 343
318 344 # Global ID with author key. If the message was downloaded from another
319 345 # server, this indicates the server.
320 346 global_id = models.OneToOneField('GlobalId', null=True, blank=True)
321 347
322 348 # One post can be signed by many nodes that give their trust to it
323 349 signature = models.ManyToManyField('Signature', null=True, blank=True)
324 350
325 def __unicode__(self):
326 return '#' + str(self.id) + ' ' + self.title + ' (' + \
327 self.text.raw[:50] + ')'
351 def __str__(self):
352 return 'P#{}/{}'.format(self.id, self.title)
353
354 def get_title(self) -> str:
355 """
356 Gets original post title or part of its text.
357 """
328 358
329 def get_title(self):
330 return self.title
359 title = self.title
360 if not title:
361 title = self.get_text()
331 362
332 def build_refmap(self):
363 return title
364
365 def build_refmap(self) -> None:
333 366 """
334 367 Builds a replies map string from replies list. This is a cache to stop
335 368 the server from recalculating the map on every post show.
336 369 """
337 370 map_string = ''
338 371
339 372 first = True
340 373 for refpost in self.referenced_posts.all():
341 374 if not first:
342 375 map_string += ', '
343 376 map_string += '<a href="%s">&gt;&gt;%s</a>' % (refpost.get_url(),
344 377 refpost.id)
345 378 first = False
346 379
347 380 self.refmap = map_string
348 381
349 382 def get_sorted_referenced_posts(self):
350 383 return self.refmap
351 384
352 def is_referenced(self):
353 return len(self.refmap) > 0
385 def is_referenced(self) -> bool:
386 if not self.refmap:
387 return False
388 else:
389 return len(self.refmap) > 0
354 390
355 def is_opening(self):
391 def is_opening(self) -> bool:
356 392 """
357 393 Checks if this is an opening post or just a reply.
358 394 """
359 395
360 396 return self.get_thread().get_opening_post_id() == self.id
361 397
362 398 @transaction.atomic
363 399 def add_tag(self, tag):
364 400 edit_time = timezone.now()
365 401
366 402 thread = self.get_thread()
367 403 thread.add_tag(tag)
368 404 self.last_edit_time = edit_time
369 405 self.save(update_fields=['last_edit_time'])
370 406
371 407 thread.last_edit_time = edit_time
372 408 thread.save(update_fields=['last_edit_time'])
373 409
374 @transaction.atomic
375 def remove_tag(self, tag):
376 edit_time = timezone.now()
377
378 thread = self.get_thread()
379 thread.remove_tag(tag)
380 self.last_edit_time = edit_time
381 self.save(update_fields=['last_edit_time'])
382
383 thread.last_edit_time = edit_time
384 thread.save(update_fields=['last_edit_time'])
385
386 410 def get_url(self, thread=None):
387 411 """
388 412 Gets full url to the post.
389 413 """
390 414
391 415 cache_key = CACHE_KEY_POST_URL + str(self.id)
392 416 link = cache.get(cache_key)
393 417
394 418 if not link:
395 419 if not thread:
396 420 thread = self.get_thread()
397 421
398 422 opening_id = thread.get_opening_post_id()
399 423
400 424 if self.id != opening_id:
401 425 link = reverse('thread', kwargs={
402 426 'post_id': opening_id}) + '#' + str(self.id)
403 427 else:
404 428 link = reverse('thread', kwargs={'post_id': self.id})
405 429
406 430 cache.set(cache_key, link)
407 431
408 432 return link
409 433
410 def get_thread(self):
434 def get_thread(self) -> Thread:
411 435 """
412 436 Gets post's thread.
413 437 """
414 438
415 439 return self.thread_new
416 440
417 441 def get_referenced_posts(self):
418 442 return self.referenced_posts.only('id', 'thread_new')
419 443
420 def get_text(self):
421 return self.text
422
423 444 def get_view(self, moderator=False, need_open_link=False,
424 445 truncated=False, *args, **kwargs):
425 if 'is_opening' in kwargs:
426 is_opening = kwargs['is_opening']
427 else:
428 is_opening = self.is_opening()
446 """
447 Renders post's HTML view. Some of the post params can be passed over
448 kwargs for the means of caching (if we view the thread, some params
449 are same for every post and don't need to be computed over and over.
450 """
429 451
430 if 'thread' in kwargs:
431 thread = kwargs['thread']
432 else:
433 thread = self.get_thread()
434
435 if 'can_bump' in kwargs:
436 can_bump = kwargs['can_bump']
437 else:
438 can_bump = thread.can_bump()
452 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
453 thread = kwargs.get(PARAMETER_THREAD, self.get_thread())
454 can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump())
439 455
440 456 if is_opening:
441 457 opening_post_id = self.id
442 458 else:
443 459 opening_post_id = thread.get_opening_post_id()
444 460
445 461 return render_to_string('boards/post.html', {
446 'post': self,
447 'moderator': moderator,
448 'is_opening': is_opening,
449 'thread': thread,
450 'bumpable': can_bump,
451 'need_open_link': need_open_link,
452 'truncated': truncated,
453 'opening_post_id': opening_post_id,
462 PARAMETER_POST: self,
463 PARAMETER_MODERATOR: moderator,
464 PARAMETER_IS_OPENING: is_opening,
465 PARAMETER_THREAD: thread,
466 PARAMETER_BUMPABLE: can_bump,
467 PARAMETER_NEED_OPEN_LINK: need_open_link,
468 PARAMETER_TRUNCATED: truncated,
469 PARAMETER_OP_ID: opening_post_id,
454 470 })
455 471
456 def get_first_image(self):
472 def get_search_view(self, *args, **kwargs):
473 return self.get_view(args, kwargs)
474
475 def get_first_image(self) -> PostImage:
457 476 return self.images.earliest('id')
458 477
459 478 def delete(self, using=None):
460 479 """
461 Deletes all post images and the post itself.
480 Deletes all post images and the post itself. If the post is opening,
481 thread with all posts is deleted.
462 482 """
463 483
464 484 self.images.all().delete()
465 485 self.signature.all().delete()
466 486 if self.global_id:
467 487 self.global_id.delete()
468 488
489 if self.is_opening():
490 self.get_thread().delete()
491 else:
492 thread = self.get_thread()
493 thread.last_edit_time = timezone.now()
494 thread.save()
495
469 496 super(Post, self).delete(using)
497 logging.getLogger('boards.post.delete').info(
498 'Deleted post {}'.format(self))
470 499
471 500 def set_global_id(self, key_pair=None):
472 501 """
473 502 Sets global id based on the given key pair. If no key pair is given,
474 503 default one is used.
475 504 """
476 505
477 506 if key_pair:
478 507 key = key_pair
479 508 else:
480 509 try:
481 510 key = KeyPair.objects.get(primary=True)
482 511 except KeyPair.DoesNotExist:
483 512 # Do not update the global id because there is no key defined
484 513 return
485 514 global_id = GlobalId(key_type=key.key_type,
486 515 key=key.public_key,
487 516 local_id = self.id)
488 517 global_id.save()
489 518
490 519 self.global_id = global_id
491 520
492 521 self.save(update_fields=['global_id'])
493 522
494 523 def get_pub_time_epoch(self):
495 524 return utils.datetime_to_epoch(self.pub_time)
496 525
526 # TODO Use this to connect replies
497 527 def get_replied_ids(self):
498 528 """
499 529 Gets ID list of the posts that this post replies.
500 530 """
501 531
502 532 local_replied = REGEX_REPLY.findall(self.text.raw)
503 533 global_replied = []
504 534 # TODO Similar code is used in mdx_neboard, maybe it can be extracted
505 535 # into a method?
506 536 for match in REGEX_GLOBAL_REPLY.findall(self.text.raw):
507 537 key_type = match[0]
508 538 key = match[1]
509 539 local_id = match[2]
510 540
511 541 try:
512 542 global_id = GlobalId.objects.get(key_type=key_type,
513 543 key=key, local_id=local_id)
514 544 for post in Post.objects.filter(global_id=global_id).only('id'):
515 545 global_replied.append(post.id)
516 546 except GlobalId.DoesNotExist:
517 547 pass
518 548 return local_replied + global_replied
549
550
551 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
552 include_last_update=False):
553 """
554 Gets post HTML or JSON data that can be rendered on a page or used by
555 API.
556 """
557
558 if format_type == DIFF_TYPE_HTML:
559 params = dict()
560 params['post'] = self
561 if PARAMETER_TRUNCATED in request.GET:
562 params[PARAMETER_TRUNCATED] = True
563
564 return render_to_string('boards/api_post.html', params)
565 elif format_type == DIFF_TYPE_JSON:
566 post_json = {
567 'id': self.id,
568 'title': self.title,
569 'text': self._text_rendered,
570 }
571 if self.images.exists():
572 post_image = self.get_first_image()
573 post_json['image'] = post_image.image.url
574 post_json['image_preview'] = post_image.image.url_200x150
575 if include_last_update:
576 post_json['bump_time'] = datetime_to_epoch(
577 self.thread_new.bump_time)
578 return post_json
579
580 def send_to_websocket(self, request, recursive=True):
581 """
582 Sends post HTML data to the thread web socket.
583 """
584
585 if not settings.WEBSOCKETS_ENABLED:
586 return
587
588 client = Client()
589
590 thread = self.get_thread()
591 thread_id = thread.id
592 channel_name = WS_CHANNEL_THREAD + str(thread.get_opening_post_id())
593 client.publish(channel_name, {
594 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
595 })
596 client.send()
597
598 logger = logging.getLogger('boards.post.websocket')
599
600 logger.info('Sent notification from post #{} to channel {}'.format(
601 self.id, channel_name))
602
603 if recursive:
604 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
605 post_id = reply_number.group(1)
606 ref_post = Post.objects.filter(id=post_id)[0]
607
608 # If post is in this thread, its thread was already notified.
609 # Otherwise, notify its thread separately.
610 if ref_post.thread_new_id != thread_id:
611 ref_post.send_to_websocket(request, recursive=False)
612
613 def save(self, force_insert=False, force_update=False, using=None,
614 update_fields=None):
615 self._text_rendered = bbcode_extended(self.get_raw_text())
616
617 super().save(force_insert, force_update, using, update_fields)
618
619 def get_text(self) -> str:
620 return self._text_rendered
621
622 def get_raw_text(self) -> str:
623 return self.text
@@ -1,78 +1,71 b''
1 1 from django.template.loader import render_to_string
2 2 from django.db import models
3 from django.db.models import Count, Sum
3 from django.db.models import Count
4 4 from django.core.urlresolvers import reverse
5 5
6 from boards.models import Thread
7 6 from boards.models.base import Viewable
8 7
9 8
10 9 __author__ = 'neko259'
11 10
12 11
13 12 class TagManager(models.Manager):
14 13
15 14 def get_not_empty_tags(self):
16 15 """
17 16 Gets tags that have non-archived threads.
18 17 """
19 18
20 tags = self.annotate(Count('threads')) \
21 .filter(threads__count__gt=0).order_by('name')
22
23 return tags
19 return self.filter(thread__archived=False)\
20 .annotate(num_threads=Count('thread')).filter(num_threads__gt=0)\
21 .order_by('-required', 'name')
24 22
25 23
26 24 class Tag(models.Model, Viewable):
27 25 """
28 26 A tag is a text node assigned to the thread. The tag serves as a board
29 27 section. There can be multiple tags for each thread
30 28 """
31 29
32 30 objects = TagManager()
33 31
34 32 class Meta:
35 33 app_label = 'boards'
36 34 ordering = ('name',)
37 35
38 36 name = models.CharField(max_length=100, db_index=True)
39 threads = models.ManyToManyField(Thread, null=True,
40 blank=True, related_name='tag+')
37 required = models.BooleanField(default=False)
41 38
42 def __unicode__(self):
39 def __str__(self):
43 40 return self.name
44 41
45 def is_empty(self):
42 def is_empty(self) -> bool:
46 43 """
47 44 Checks if the tag has some threads.
48 45 """
49 46
50 47 return self.get_thread_count() == 0
51 48
52 def get_thread_count(self):
53 return self.threads.count()
54
55 def get_post_count(self, archived=False):
56 """
57 Gets posts count for the tag's threads.
58 """
59
60 posts_count = 0
61
62 threads = self.threads.filter(archived=archived)
63 if threads.exists():
64 posts_count = threads.annotate(posts_count=Count('replies')) \
65 .aggregate(posts_sum=Sum('posts_count'))['posts_sum']
66
67 if not posts_count:
68 posts_count = 0
69
70 return posts_count
49 def get_thread_count(self) -> int:
50 return self.get_threads().count()
71 51
72 52 def get_url(self):
73 53 return reverse('tag', kwargs={'tag_name': self.name})
74 54
75 def get_view(self, *args, **kwargs):
55 def get_threads(self):
56 return self.thread_set.order_by('-bump_time')
57
58 def is_required(self):
59 return self.required
60
61 def get_view(self):
62 link = '<a class="tag" href="{}">{}</a>'.format(
63 self.get_url(), self.name)
64 if self.is_required():
65 link = '<b>{}</b>'.format(link)
66 return link
67
68 def get_search_view(self, *args, **kwargs):
76 69 return render_to_string('boards/tag.html', {
77 70 'tag': self,
78 71 })
@@ -1,188 +1,182 b''
1 1 import logging
2 from django.db.models import Count
2 from django.db.models import Count, Sum
3 3 from django.utils import timezone
4 4 from django.core.cache import cache
5 5 from django.db import models
6 6 from boards import settings
7 7
8 8 __author__ = 'neko259'
9 9
10 10
11 11 logger = logging.getLogger(__name__)
12 12
13 13
14 14 CACHE_KEY_OPENING_POST = 'opening_post_id'
15 15
16 16
17 17 class ThreadManager(models.Manager):
18 18 def process_oldest_threads(self):
19 19 """
20 20 Preserves maximum thread count. If there are too many threads,
21 21 archive or delete the old ones.
22 22 """
23 23
24 24 threads = Thread.objects.filter(archived=False).order_by('-bump_time')
25 25 thread_count = threads.count()
26 26
27 27 if thread_count > settings.MAX_THREAD_COUNT:
28 28 num_threads_to_delete = thread_count - settings.MAX_THREAD_COUNT
29 29 old_threads = threads[thread_count - num_threads_to_delete:]
30 30
31 31 for thread in old_threads:
32 32 if settings.ARCHIVE_THREADS:
33 33 self._archive_thread(thread)
34 34 else:
35 35 thread.delete()
36 36
37 37 logger.info('Processed %d old threads' % num_threads_to_delete)
38 38
39 39 def _archive_thread(self, thread):
40 40 thread.archived = True
41 thread.bumpable = False
41 42 thread.last_edit_time = timezone.now()
42 thread.save(update_fields=['archived', 'last_edit_time'])
43 thread.save(update_fields=['archived', 'last_edit_time', 'bumpable'])
43 44
44 45
45 46 class Thread(models.Model):
46 47 objects = ThreadManager()
47 48
48 49 class Meta:
49 50 app_label = 'boards'
50 51
51 52 tags = models.ManyToManyField('Tag')
52 53 bump_time = models.DateTimeField()
53 54 last_edit_time = models.DateTimeField()
54 55 replies = models.ManyToManyField('Post', symmetrical=False, null=True,
55 56 blank=True, related_name='tre+')
56 57 archived = models.BooleanField(default=False)
58 bumpable = models.BooleanField(default=True)
57 59
58 60 def get_tags(self):
59 61 """
60 62 Gets a sorted tag list.
61 63 """
62 64
63 65 return self.tags.order_by('name')
64 66
65 67 def bump(self):
66 68 """
67 69 Bumps (moves to up) thread if possible.
68 70 """
69 71
70 72 if self.can_bump():
71 73 self.bump_time = timezone.now()
72 74
75 if self.get_reply_count() >= settings.MAX_POSTS_PER_THREAD:
76 self.bumpable = False
77
73 78 logger.info('Bumped thread %d' % self.id)
74 79
75 80 def get_reply_count(self):
76 81 return self.replies.count()
77 82
78 83 def get_images_count(self):
79 # TODO Use sum
80 total_count = 0
81 for post_with_image in self.replies.annotate(images_count=Count(
82 'images')):
83 total_count += post_with_image.images_count
84 return total_count
84 return self.replies.annotate(images_count=Count(
85 'images')).aggregate(Sum('images_count'))['images_count__sum']
85 86
86 87 def can_bump(self):
87 88 """
88 89 Checks if the thread can be bumped by replying to it.
89 90 """
90 91
91 if self.archived:
92 return False
93
94 post_count = self.get_reply_count()
95
96 return post_count < settings.MAX_POSTS_PER_THREAD
92 return self.bumpable
97 93
98 94 def get_last_replies(self):
99 95 """
100 96 Gets several last replies, not including opening post
101 97 """
102 98
103 99 if settings.LAST_REPLIES_COUNT > 0:
104 100 reply_count = self.get_reply_count()
105 101
106 102 if reply_count > 0:
107 103 reply_count_to_show = min(settings.LAST_REPLIES_COUNT,
108 104 reply_count - 1)
109 105 replies = self.get_replies()
110 106 last_replies = replies[reply_count - reply_count_to_show:]
111 107
112 108 return last_replies
113 109
114 110 def get_skipped_replies_count(self):
115 111 """
116 112 Gets number of posts between opening post and last replies.
117 113 """
118 114 reply_count = self.get_reply_count()
119 115 last_replies_count = min(settings.LAST_REPLIES_COUNT,
120 116 reply_count - 1)
121 117 return reply_count - last_replies_count - 1
122 118
123 119 def get_replies(self, view_fields_only=False):
124 120 """
125 121 Gets sorted thread posts
126 122 """
127 123
128 124 query = self.replies.order_by('pub_time').prefetch_related('images')
129 125 if view_fields_only:
130 query = query.defer('poster_user_agent', 'text_markup_type')
126 query = query.defer('poster_user_agent')
131 127 return query.all()
132 128
133 129 def get_replies_with_images(self, view_fields_only=False):
134 130 return self.get_replies(view_fields_only).annotate(images_count=Count(
135 131 'images')).filter(images_count__gt=0)
136 132
137 133 def add_tag(self, tag):
138 134 """
139 135 Connects thread to a tag and tag to a thread
140 136 """
141 137
142 138 self.tags.add(tag)
143 tag.threads.add(self)
144
145 def remove_tag(self, tag):
146 self.tags.remove(tag)
147 tag.threads.remove(self)
148 139
149 140 def get_opening_post(self, only_id=False):
150 141 """
151 142 Gets the first post of the thread
152 143 """
153 144
154 145 query = self.replies.order_by('pub_time')
155 146 if only_id:
156 147 query = query.only('id')
157 148 opening_post = query.first()
158 149
159 150 return opening_post
160 151
161 152 def get_opening_post_id(self):
162 153 """
163 154 Gets ID of the first thread post.
164 155 """
165 156
166 157 cache_key = CACHE_KEY_OPENING_POST + str(self.id)
167 158 opening_post_id = cache.get(cache_key)
168 159 if not opening_post_id:
169 160 opening_post_id = self.get_opening_post(only_id=True).id
170 161 cache.set(cache_key, opening_post_id)
171 162
172 163 return opening_post_id
173 164
174 165 def __unicode__(self):
175 166 return str(self.id)
176 167
177 168 def get_pub_time(self):
178 169 """
179 170 Gets opening post's pub time because thread does not have its own one.
180 171 """
181 172
182 173 return self.get_opening_post().pub_time
183 174
184 175 def delete(self, using=None):
185 176 if self.replies.exists():
186 177 self.replies.all().delete()
187 178
188 super(Thread, self).delete(using) No newline at end of file
179 super(Thread, self).delete(using)
180
181 def __str__(self):
182 return 'T#{}/{}'.format(self.id, self.get_opening_post_id()) No newline at end of file
@@ -1,24 +1,24 b''
1 1 from haystack import indexes
2 2 from boards.models import Post, Tag
3 3
4 4 __author__ = 'neko259'
5 5
6 6
7 7 class PostIndex(indexes.SearchIndex, indexes.Indexable):
8 8 text = indexes.CharField(document=True, use_template=True)
9 9
10 10 def get_model(self):
11 11 return Post
12 12
13 13 def index_queryset(self, using=None):
14 14 return self.get_model().objects.all()
15 15
16 16
17 17 class TagIndex(indexes.SearchIndex, indexes.Indexable):
18 18 text = indexes.CharField(document=True, use_template=True)
19 19
20 20 def get_model(self):
21 21 return Tag
22 22
23 23 def index_queryset(self, using=None):
24 return self.get_model().objects.get_not_empty_tags()
24 return self.get_model().objects.all()
@@ -1,20 +1,22 b''
1 VERSION = '2.1 Aya'
2 SITE_NAME = 'n3b0a2d'
1 VERSION = '2.2.3 Miyu'
2 SITE_NAME = 'Neboard'
3 3
4 4 CACHE_TIMEOUT = 600 # Timeout for caching, if cache is used
5 5 LOGIN_TIMEOUT = 3600 # Timeout between login tries
6 6 MAX_TEXT_LENGTH = 30000 # Max post length in characters
7 7 MAX_IMAGE_SIZE = 8 * 1024 * 1024 # Max image size
8 8
9 9 # Thread bumplimit
10 10 MAX_POSTS_PER_THREAD = 10
11 11 # Old posts will be archived or deleted if this value is reached
12 12 MAX_THREAD_COUNT = 5
13 13 THREADS_PER_PAGE = 3
14 14 DEFAULT_THEME = 'md'
15 15 LAST_REPLIES_COUNT = 3
16 16
17 17 # Enable archiving threads instead of deletion when the thread limit is reached
18 18 ARCHIVE_THREADS = True
19 19 # Limit posting speed
20 20 LIMIT_POSTING_SPEED = False
21 # Thread update
22 WEBSOCKETS_ENABLED = True
@@ -1,484 +1,497 b''
1 * {
2 text-decoration: none;
3 font-weight: inherit;
4 }
5
6 b {
7 font-weight: bold;
8 }
9
1 10 html {
2 11 background: #555;
3 12 color: #ffffff;
4 13 }
5 14
6 15 body {
7 16 margin: 0;
8 17 }
9 18
10 19 #admin_panel {
11 20 background: #FF0000;
12 21 color: #00FF00
13 22 }
14 23
15 24 .input_field_error {
16 25 color: #FF0000;
17 26 }
18 27
19 28 .title {
20 29 font-weight: bold;
21 30 color: #ffcc00;
22 31 }
23 32
24 33 .link, a {
25 34 color: #afdcec;
26 35 }
27 36
28 37 .block {
29 38 display: inline-block;
30 39 vertical-align: top;
31 40 }
32 41
33 42 .tag {
34 43 color: #FFD37D;
35 44 }
36 45
37 46 .post_id {
38 47 color: #fff380;
39 48 }
40 49
41 50 .post, .dead_post, .archive_post, #posts-table {
42 51 background: #333;
43 52 padding: 10px;
44 53 clear: left;
45 54 word-wrap: break-word;
46 55 border-top: 1px solid #777;
47 56 border-bottom: 1px solid #777;
48 57 }
49 58
50 59 .post + .post {
51 60 border-top: none;
52 61 }
53 62
54 63 .dead_post + .dead_post {
55 64 border-top: none;
56 65 }
57 66
58 67 .archive_post + .archive_post {
59 68 border-top: none;
60 69 }
61 70
62 71 .metadata {
63 72 padding-top: 5px;
64 73 margin-top: 10px;
65 74 border-top: solid 1px #666;
66 75 color: #ddd;
67 76 }
68 77
69 78 .navigation_panel, .tag_info {
70 79 background: #222;
71 80 margin-bottom: 5px;
72 81 margin-top: 5px;
73 82 padding: 10px;
74 83 border-bottom: solid 1px #888;
75 84 border-top: solid 1px #888;
76 85 color: #eee;
77 86 }
78 87
79 88 .navigation_panel .link {
80 89 border-right: 1px solid #fff;
81 90 font-weight: bold;
82 91 margin-right: 1ex;
83 92 padding-right: 1ex;
84 93 }
85 94 .navigation_panel .link:last-child {
86 95 border-left: 1px solid #fff;
87 96 border-right: none;
88 97 float: right;
89 98 margin-left: 1ex;
90 99 margin-right: 0;
91 100 padding-left: 1ex;
92 101 padding-right: 0;
93 102 }
94 103
95 104 .navigation_panel::after, .post::after {
96 105 clear: both;
97 106 content: ".";
98 107 display: block;
99 108 height: 0;
100 109 line-height: 0;
101 110 visibility: hidden;
102 111 }
103 112
104 113 .header {
105 114 border-bottom: solid 2px #ccc;
106 115 margin-bottom: 5px;
107 116 border-top: none;
108 117 margin-top: 0;
109 118 }
110 119
111 120 .footer {
112 121 border-top: solid 2px #ccc;
113 122 margin-top: 5px;
114 123 border-bottom: none;
115 124 margin-bottom: 0;
116 125 }
117 126
118 127 p, .br {
119 128 margin-top: .5em;
120 129 margin-bottom: .5em;
121 130 }
122 131
123 132 .post-form-w {
124 133 background: #333344;
125 134 border-top: solid 1px #888;
126 135 border-bottom: solid 1px #888;
127 136 color: #fff;
128 137 padding: 10px;
129 138 margin-bottom: 5px;
130 139 margin-top: 5px;
131 140 }
132 141
133 142 .form-row {
134 143 width: 100%;
135 144 }
136 145
137 146 .form-label {
138 147 padding: .25em 1ex .25em 0;
139 148 vertical-align: top;
140 149 }
141 150
142 151 .form-input {
143 152 padding: .25em 0;
144 153 }
145 154
146 155 .form-errors {
147 156 font-weight: bolder;
148 157 vertical-align: middle;
149 158 }
150 159
151 160 .post-form input:not([name="image"]), .post-form textarea {
152 161 background: #333;
153 162 color: #fff;
154 163 border: solid 1px;
155 164 padding: 0;
156 165 font: medium sans-serif;
157 166 width: 100%;
158 167 }
159 168
169 .post-form textarea {
170 resize: vertical;
171 }
172
160 173 .form-submit {
161 174 display: table;
162 175 margin-bottom: 1ex;
163 176 margin-top: 1ex;
164 177 }
165 178
166 179 .form-title {
167 180 font-weight: bold;
168 181 font-size: 2ex;
169 182 margin-bottom: 0.5ex;
170 183 }
171 184
172 185 .post-form input[type="submit"], input[type="submit"] {
173 186 background: #222;
174 187 border: solid 2px #fff;
175 188 color: #fff;
176 189 padding: 0.5ex;
177 190 }
178 191
179 192 input[type="submit"]:hover {
180 193 background: #060;
181 194 }
182 195
183 196 blockquote {
184 197 border-left: solid 2px;
185 198 padding-left: 5px;
186 199 color: #B1FB17;
187 200 margin: 0;
188 201 }
189 202
190 203 .post > .image {
191 204 float: left;
192 205 margin: 0 1ex .5ex 0;
193 206 min-width: 1px;
194 207 text-align: center;
195 208 display: table-row;
196 209 }
197 210
198 211 .post > .metadata {
199 212 clear: left;
200 213 }
201 214
202 215 .get {
203 216 font-weight: bold;
204 217 color: #d55;
205 218 }
206 219
207 220 * {
208 221 text-decoration: none;
209 222 }
210 223
211 224 .dead_post {
212 225 background-color: #442222;
213 226 }
214 227
215 228 .archive_post {
216 229 background-color: #000;
217 230 }
218 231
219 232 .mark_btn {
220 233 border: 1px solid;
221 234 min-width: 2ex;
222 235 padding: 2px 2ex;
223 236 }
224 237
225 238 .mark_btn:hover {
226 239 background: #555;
227 240 }
228 241
229 242 .quote {
230 243 color: #92cf38;
231 244 font-style: italic;
232 245 }
233 246
234 247 .multiquote {
235 248 padding: 3px;
236 249 display: inline-block;
237 250 background: #222;
238 251 border-style: solid;
239 252 border-width: 1px 1px 1px 4px;
240 253 font-size: 0.9em;
241 254 }
242 255
243 256 .spoiler {
244 257 background: white;
245 258 color: white;
246 259 }
247 260
248 261 .spoiler:hover {
249 262 color: black;
250 263 }
251 264
252 265 .comment {
253 266 color: #eb2;
254 267 }
255 268
256 269 a:hover {
257 270 text-decoration: underline;
258 271 }
259 272
260 273 .last-replies {
261 274 margin-left: 3ex;
262 275 margin-right: 3ex;
263 276 border-left: solid 1px #777;
264 277 border-right: solid 1px #777;
265 278 }
266 279
267 280 .last-replies > .post:first-child {
268 281 border-top: none;
269 282 }
270 283
271 284 .thread {
272 285 margin-bottom: 3ex;
273 286 margin-top: 1ex;
274 287 }
275 288
276 289 .post:target {
277 290 border: solid 2px white;
278 291 }
279 292
280 293 pre{
281 294 white-space:pre-wrap
282 295 }
283 296
284 297 li {
285 298 list-style-position: inside;
286 299 }
287 300
288 301 .fancybox-skin {
289 302 position: relative;
290 303 background-color: #fff;
291 304 color: #ddd;
292 305 text-shadow: none;
293 306 }
294 307
295 308 .fancybox-image {
296 309 border: 1px solid black;
297 310 }
298 311
299 312 .image-mode-tab {
300 313 background: #444;
301 314 color: #eee;
302 315 margin-top: 5px;
303 316 padding: 5px;
304 317 border-top: 1px solid #888;
305 318 border-bottom: 1px solid #888;
306 319 }
307 320
308 321 .image-mode-tab > label {
309 322 margin: 0 1ex;
310 323 }
311 324
312 325 .image-mode-tab > label > input {
313 326 margin-right: .5ex;
314 327 }
315 328
316 329 #posts-table {
317 330 margin-top: 5px;
318 331 margin-bottom: 5px;
319 332 }
320 333
321 334 .tag_info > h2 {
322 335 margin: 0;
323 336 }
324 337
325 338 .post-info {
326 339 color: #ddd;
327 340 margin-bottom: 1ex;
328 341 }
329 342
330 343 .moderator_info {
331 344 color: #e99d41;
332 345 float: right;
333 346 font-weight: bold;
334 347 }
335 348
336 349 .refmap {
337 350 font-size: 0.9em;
338 351 color: #ccc;
339 352 margin-top: 1em;
340 353 }
341 354
342 355 .fav {
343 356 color: yellow;
344 357 }
345 358
346 359 .not_fav {
347 360 color: #ccc;
348 361 }
349 362
350 363 .role {
351 364 text-decoration: underline;
352 365 }
353 366
354 367 .form-email {
355 368 display: none;
356 369 }
357 370
358 371 .bar-value {
359 372 background: rgba(50, 55, 164, 0.45);
360 373 font-size: 0.9em;
361 374 height: 1.5em;
362 375 }
363 376
364 377 .bar-bg {
365 378 position: relative;
366 379 border-top: solid 1px #888;
367 380 border-bottom: solid 1px #888;
368 381 margin-top: 5px;
369 382 overflow: hidden;
370 383 }
371 384
372 385 .bar-text {
373 386 padding: 2px;
374 387 position: absolute;
375 388 left: 0;
376 389 top: 0;
377 390 }
378 391
379 392 .page_link {
380 393 background: #444;
381 394 border-top: solid 1px #888;
382 395 border-bottom: solid 1px #888;
383 396 padding: 5px;
384 397 color: #eee;
385 398 font-size: 2ex;
386 399 }
387 400
388 401 .skipped_replies {
389 402 padding: 5px;
390 403 margin-left: 3ex;
391 404 margin-right: 3ex;
392 405 border-left: solid 1px #888;
393 406 border-right: solid 1px #888;
394 407 border-bottom: solid 1px #888;
395 408 background: #000;
396 409 }
397 410
398 411 .current_page {
399 412 padding: 2px;
400 413 background-color: #afdcec;
401 414 color: #000;
402 415 }
403 416
404 417 .current_mode {
405 418 font-weight: bold;
406 419 }
407 420
408 421 .gallery_image {
409 422 border: solid 1px;
410 423 padding: 0.5ex;
411 424 margin: 0.5ex;
412 425 text-align: center;
413 426 }
414 427
415 428 code {
416 429 border: dashed 1px #ccc;
417 430 background: #111;
418 431 padding: 2px;
419 432 font-size: 1.2em;
420 433 display: inline-block;
421 434 }
422 435
423 436 pre {
424 437 overflow: auto;
425 438 }
426 439
427 440 .img-full {
428 441 background: #222;
429 442 border: solid 1px white;
430 443 }
431 444
432 445 .tag_item {
433 446 display: inline-block;
434 447 border: 1px dashed #666;
435 448 margin: 0.2ex;
436 449 padding: 0.1ex;
437 450 }
438 451
439 452 #id_models li {
440 453 list-style: none;
441 454 }
442 455
443 456 #id_q {
444 457 margin-left: 1ex;
445 458 }
446 459
447 460 ul {
448 461 padding-left: 0px;
449 462 }
450 463
451 464 .quote-header {
452 465 border-bottom: 2px solid #ddd;
453 466 margin-bottom: 1ex;
454 467 padding-bottom: .5ex;
455 468 color: #ddd;
456 469 font-size: 1.2em;
457 470 }
458 471
459 472 .global-id {
460 473 font-weight: bolder;
461 474 opacity: .5;
462 475 }
463 476
464 477 /* Post */
465 478 .post > .message, .post > .image {
466 479 padding-left: 1em;
467 480 }
468 481
469 482 /* Reflink preview */
470 483 .post_preview {
471 484 border-left: 1px solid #777;
472 485 border-right: 1px solid #777;
473 486 }
474 487
475 488 /* Code highlighter */
476 489 .hljs {
477 490 color: #fff;
478 491 background: #000;
479 492 display: inline-block;
480 493 }
481 494
482 495 .hljs, .hljs-subst, .hljs-tag .hljs-title, .lisp .hljs-title, .clojure .hljs-built_in, .nginx .hljs-title {
483 496 color: #fff;
484 497 }
@@ -1,288 +1,334 b''
1 1 /*
2 2 @licstart The following is the entire license notice for the
3 3 JavaScript code in this page.
4 4
5 5
6 Copyright (C) 2013 neko259
6 Copyright (C) 2013-2014 neko259
7 7
8 8 The JavaScript code in this page is free software: you can
9 9 redistribute it and/or modify it under the terms of the GNU
10 10 General Public License (GNU GPL) as published by the Free Software
11 11 Foundation, either version 3 of the License, or (at your option)
12 12 any later version. The code is distributed WITHOUT ANY WARRANTY;
13 13 without even the implied warranty of MERCHANTABILITY or FITNESS
14 14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
15 15
16 16 As additional permission under GNU GPL version 3 section 7, you
17 17 may distribute non-source (e.g., minimized or compacted) forms of
18 18 that code without the copy of the GNU GPL normally required by
19 19 section 4, provided you include this license notice and a URL
20 20 through which recipients can access the Corresponding Source.
21 21
22 22 @licend The above is the entire license notice
23 23 for the JavaScript code in this page.
24 24 */
25 25
26 var THREAD_UPDATE_DELAY = 10000;
26 var wsUser = '';
27 27
28 28 var loading = false;
29 var lastUpdateTime = null;
30 29 var unreadPosts = 0;
30 var documentOriginalTitle = '';
31 31
32 // Thread ID does not change, can be stored one time
33 var threadId = $('div.thread').children('.post').first().attr('id');
34
35 /**
36 * Connect to websocket server and subscribe to thread updates. On any update we
37 * request a thread diff.
38 *
39 * @returns {boolean} true if connected, false otherwise
40 */
41 function connectWebsocket() {
42 var metapanel = $('.metapanel')[0];
43
44 var wsHost = metapanel.getAttribute('data-ws-host');
45 var wsPort = metapanel.getAttribute('data-ws-port');
46
47 if (wsHost.length > 0 && wsPort.length > 0)
48 var centrifuge = new Centrifuge({
49 "url": 'ws://' + wsHost + ':' + wsPort + "/connection/websocket",
50 "project": metapanel.getAttribute('data-ws-project'),
51 "user": wsUser,
52 "timestamp": metapanel.getAttribute('data-last-update'),
53 "token": metapanel.getAttribute('data-ws-token'),
54 "debug": false
55 });
56
57 centrifuge.on('error', function(error_message) {
58 console.log("Error connecting to websocket server.");
59 return false;
60 });
61
62 centrifuge.on('connect', function() {
63 var channelName = 'thread:' + threadId;
64 centrifuge.subscribe(channelName, function(message) {
65 getThreadDiff();
66 });
67
68 // For the case we closed the browser and missed some updates
69 getThreadDiff();
70 $('#autoupdate').text('[+]');
71 });
72
73 centrifuge.connect();
74
75 return true;
76 }
77
78 /**
79 * Get diff of the posts from the current thread timestamp.
80 * This is required if the browser was closed and some post updates were
81 * missed.
82 */
83 function getThreadDiff() {
84 var lastUpdateTime = $('.metapanel').attr('data-last-update');
85
86 var diffUrl = '/api/diff_thread/' + threadId + '/' + lastUpdateTime + '/';
87
88 $.getJSON(diffUrl)
89 .success(function(data) {
90 var addedPosts = data.added;
91
92 for (var i = 0; i < addedPosts.length; i++) {
93 var postText = addedPosts[i];
94 var post = $(postText);
95
96 updatePost(post)
97
98 lastPost = post;
99 }
100
101 var updatedPosts = data.updated;
102
103 for (var i = 0; i < updatedPosts.length; i++) {
104 var postText = updatedPosts[i];
105 var post = $(postText);
106
107 updatePost(post)
108 }
109
110 // TODO Process removed posts if any
111 $('.metapanel').attr('data-last-update', data.last_update);
112 })
113 }
114
115 /**
116 * Add or update the post on html page.
117 */
118 function updatePost(postHtml) {
119 // This needs to be set on start because the page is scrolled after posts
120 // are added or updated
121 var bottom = isPageBottom();
122
123 var post = $(postHtml);
124
125 var threadBlock = $('div.thread');
126
127 var lastUpdate = '';
128
129 var postId = post.attr('id');
130
131 // If the post already exists, replace it. Otherwise add as a new one.
132 var existingPosts = threadBlock.children('.post[id=' + postId + ']');
133
134 if (existingPosts.size() > 0) {
135 existingPosts.replaceWith(post);
136 } else {
137 var threadPosts = threadBlock.children('.post');
138 var lastPost = threadPosts.last();
139
140 post.appendTo(lastPost.parent());
141
142 updateBumplimitProgress(1);
143 showNewPostsTitle(1);
144
145 lastUpdate = post.children('.post-info').first()
146 .children('.pub_time').first().text();
147
148 if (bottom) {
149 scrollToBottom();
150 }
151 }
152
153 processNewPost(post);
154 updateMetadataPanel(lastUpdate)
155 }
156
157 /**
158 * Initiate a blinking animation on a node to show it was updated.
159 */
32 160 function blink(node) {
33 161 var blinkCount = 2;
34 162
35 163 var nodeToAnimate = node;
36 164 for (var i = 0; i < blinkCount; i++) {
37 165 nodeToAnimate = nodeToAnimate.fadeTo('fast', 0.5).fadeTo('fast', 1.0);
38 166 }
39 167 }
40 168
41 function updateThread() {
42 if (loading) {
43 return;
44 }
45
46 loading = true;
47
48 var threadPosts = $('div.thread').children('.post');
49
50 var lastPost = threadPosts.last();
51 var threadId = threadPosts.first().attr('id');
52
53 var diffUrl = '/api/diff_thread/' + threadId + '/' + lastUpdateTime + '/';
54 $.getJSON(diffUrl)
55 .success(function(data) {
56 var bottom = isPageBottom();
57
58 var lastUpdate = '';
59
60 var addedPosts = data.added;
61 for (var i = 0; i < addedPosts.length; i++) {
62 var postText = addedPosts[i];
63
64 var post = $(postText);
65
66 if (lastUpdate === '') {
67 lastUpdate = post.find('.pub_time').text();
68 }
69
70 post.appendTo(lastPost.parent());
71 processNewPost(post);
72
73 lastPost = post;
74 blink(post);
75 }
76
77 var updatedPosts = data.updated;
78 for (var i = 0; i < updatedPosts.length; i++) {
79 var postText = updatedPosts[i];
80
81 var post = $(postText);
82
83 if (lastUpdate === '') {
84 lastUpdate = post.find('.pub_time').text();
85 }
86
87 var postId = post.attr('id');
88
89 var oldPost = $('div.thread').children('.post[id=' + postId + ']');
90
91 oldPost.replaceWith(post);
92 processNewPost(post);
93
94 blink(post);
95 }
96
97 // TODO Process deleted posts
98
99 lastUpdateTime = data.last_update;
100 loading = false;
101
102 if (bottom) {
103 scrollToBottom();
104 }
105
106 var hasPostChanges = (updatedPosts.length > 0)
107 || (addedPosts.length > 0);
108 if (hasPostChanges) {
109 updateMetadataPanel(lastUpdate);
110 }
111
112 updateBumplimitProgress(data.added.length);
113
114 if (data.added.length + data.updated.length > 0) {
115 showNewPostsTitle(data.added.length);
116 }
117 })
118 .error(function(data) {
119 // TODO Show error message that server is unavailable?
120
121 loading = false;
122 });
123 }
124
125 169 function isPageBottom() {
126 170 var scroll = $(window).scrollTop() / ($(document).height()
127 - $(window).height())
171 - $(window).height());
128 172
129 173 return scroll == 1
130 174 }
131 175
132 176 function initAutoupdate() {
133 loading = false;
134
135 lastUpdateTime = $('.metapanel').attr('data-last-update');
136
137 setInterval(updateThread, THREAD_UPDATE_DELAY);
177 return connectWebsocket();
138 178 }
139 179
140 180 function getReplyCount() {
141 181 return $('.thread').children('.post').length
142 182 }
143 183
144 184 function getImageCount() {
145 185 return $('.thread').find('img').length
146 186 }
147 187
188 /**
189 * Update post count, images count and last update time in the metadata
190 * panel.
191 */
148 192 function updateMetadataPanel(lastUpdate) {
149 193 var replyCountField = $('#reply-count');
150 194 var imageCountField = $('#image-count');
151 195
152 196 replyCountField.text(getReplyCount());
153 197 imageCountField.text(getImageCount());
154 198
155 199 if (lastUpdate !== '') {
156 200 var lastUpdateField = $('#last-update');
157 201 lastUpdateField.text(lastUpdate);
158 202 blink(lastUpdateField);
159 203 }
160 204
161 205 blink(replyCountField);
162 206 blink(imageCountField);
163 207 }
164 208
165 209 /**
166 210 * Update bumplimit progress bar
167 211 */
168 212 function updateBumplimitProgress(postDelta) {
169 213 var progressBar = $('#bumplimit_progress');
170 214 if (progressBar) {
171 215 var postsToLimitElement = $('#left_to_limit');
172 216
173 217 var oldPostsToLimit = parseInt(postsToLimitElement.text());
174 218 var postCount = getReplyCount();
175 219 var bumplimit = postCount - postDelta + oldPostsToLimit;
176 220
177 221 var newPostsToLimit = bumplimit - postCount;
178 222 if (newPostsToLimit <= 0) {
179 223 $('.bar-bg').remove();
180 224 $('.thread').children('.post').addClass('dead_post');
181 225 } else {
182 226 postsToLimitElement.text(newPostsToLimit);
183 227 progressBar.width((100 - postCount / bumplimit * 100.0) + '%');
184 228 }
185 229 }
186 230 }
187 231
188 var documentOriginalTitle = '';
189 232 /**
190 233 * Show 'new posts' text in the title if the document is not visible to a user
191 234 */
192 235 function showNewPostsTitle(newPostCount) {
193 236 if (document.hidden) {
194 237 if (documentOriginalTitle === '') {
195 238 documentOriginalTitle = document.title;
196 239 }
197 240 unreadPosts = unreadPosts + newPostCount;
198 241 document.title = '[' + unreadPosts + '] ' + documentOriginalTitle;
199 242
200 243 document.addEventListener('visibilitychange', function() {
201 244 if (documentOriginalTitle !== '') {
202 245 document.title = documentOriginalTitle;
203 246 documentOriginalTitle = '';
204 247 unreadPosts = 0;
205 248 }
206 249
207 250 document.removeEventListener('visibilitychange', null);
208 251 });
209 252 }
210 253 }
211 254
212 255 /**
213 256 * Clear all entered values in the form fields
214 257 */
215 258 function resetForm(form) {
216 259 form.find('input:text, input:password, input:file, select, textarea').val('');
217 260 form.find('input:radio, input:checkbox')
218 261 .removeAttr('checked').removeAttr('selected');
219 262 $('.file_wrap').find('.file-thumb').remove();
220 263 }
221 264
222 265 /**
223 266 * When the form is posted, this method will be run as a callback
224 267 */
225 268 function updateOnPost(response, statusText, xhr, form) {
226 269 var json = $.parseJSON(response);
227 270 var status = json.status;
228 271
229 272 showAsErrors(form, '');
230 273
231 274 if (status === 'ok') {
232 275 resetForm(form);
233 updateThread();
276 getThreadDiff();
234 277 } else {
235 278 var errors = json.errors;
236 279 for (var i = 0; i < errors.length; i++) {
237 280 var fieldErrors = errors[i];
238 281
239 282 var error = fieldErrors.errors;
240 283
241 284 showAsErrors(form, error);
242 285 }
243 286 }
287
288 scrollToBottom();
244 289 }
245 290
246 291 /**
247 292 * Show text in the errors row of the form.
248 293 * @param form
249 294 * @param text
250 295 */
251 296 function showAsErrors(form, text) {
252 297 form.children('.form-errors').remove();
253 298
254 299 if (text.length > 0) {
255 300 var errorList = $('<div class="form-errors">' + text
256 301 + '<div>');
257 302 errorList.appendTo(form);
258 303 }
259 304 }
260 305
261 306 /**
262 307 * Run js methods that are usually run on the document, on the new post
263 308 */
264 309 function processNewPost(post) {
265 310 addRefLinkPreview(post[0]);
266 311 highlightCode(post);
312 blink(post);
267 313 }
268 314
269 315 $(document).ready(function(){
270 initAutoupdate();
316 if (initAutoupdate()) {
317 // Post form data over AJAX
318 var threadId = $('div.thread').children('.post').first().attr('id');
271 319
272 // Post form data over AJAX
273 var threadId = $('div.thread').children('.post').first().attr('id');
274
275 var form = $('#form');
320 var form = $('#form');
276 321
277 var options = {
278 beforeSubmit: function(arr, $form, options) {
279 showAsErrors($('form'), gettext('Sending message...'));
280 },
281 success: updateOnPost,
282 url: '/api/add_post/' + threadId + '/'
283 };
322 var options = {
323 beforeSubmit: function(arr, $form, options) {
324 showAsErrors($('form'), gettext('Sending message...'));
325 },
326 success: updateOnPost,
327 url: '/api/add_post/' + threadId + '/'
328 };
284 329
285 form.ajaxForm(options);
330 form.ajaxForm(options);
286 331
287 resetForm(form);
332 resetForm(form);
333 }
288 334 });
@@ -1,58 +1,60 b''
1 1 {% load staticfiles %}
2 2 {% load i18n %}
3 3 {% load l10n %}
4 4 {% load static from staticfiles %}
5 5
6 6 <!DOCTYPE html>
7 7 <html>
8 8 <head>
9 9 <link rel="stylesheet" type="text/css" href="{% static 'css/base.css' %}" media="all"/>
10 10 <link rel="stylesheet" type="text/css" href="{% static 'css/3party/highlight.css' %}" media="all"/>
11 11 <link rel="stylesheet" type="text/css" href="{% static theme_css %}" media="all"/>
12
12 13 <link rel="alternate" type="application/rss+xml" href="rss/" title="{% trans 'Feed' %}"/>
13 14
14 15 <link rel="icon" type="image/png"
15 16 href="{% static 'favicon.png' %}">
16 17
17 18 <meta name="viewport" content="width=device-width, initial-scale=1"/>
18 19 <meta charset="utf-8"/>
19 20
20 21 {% block head %}{% endblock %}
21 22 </head>
22 23 <body>
23 24 <script src="{% static 'js/jquery-2.0.1.min.js' %}"></script>
24 25 <script src="{% static 'js/jquery-ui-1.10.3.custom.min.js' %}"></script>
25 26 <script src="{% static 'js/jquery.mousewheel.js' %}"></script>
26 27 <script src="{% url 'js_info_dict' %}"></script>
27 28
28 29 <div class="navigation_panel header">
29 30 <a class="link" href="{% url 'index' %}">{% trans "All threads" %}</a>
30 31 {% for tag in tags %}
31 <a class="tag" href="{% url 'tag' tag_name=tag.name %}"
32 >#{{ tag.name }}</a>,
32 {% autoescape off %}
33 {{ tag.get_view }}{% if not forloop.last %},{% endif %}
34 {% endautoescape %}
33 35 {% endfor %}
34 36 <a href="{% url 'tags' %}" title="{% trans 'Tag management' %}"
35 37 >[...]</a>,
36 38 <a href="{% url 'search' %}" title="{% trans 'Search' %}">[S]</a>
37 39 <a class="link" href="{% url 'settings' %}">{% trans 'Settings' %}</a>
38 40 </div>
39 41
40 42 {% block content %}{% endblock %}
41 43
44 <script src="{% static 'js/3party/highlight.min.js' %}"></script>
42 45 <script src="{% static 'js/popup.js' %}"></script>
43 46 <script src="{% static 'js/image.js' %}"></script>
44 <script src="{% static 'js/3party/highlight.min.js' %}"></script>
45 47 <script src="{% static 'js/refpopup.js' %}"></script>
46 48 <script src="{% static 'js/main.js' %}"></script>
47 49
48 50 <div class="navigation_panel footer">
49 51 {% block metapanel %}{% endblock %}
50 52 [<a href="{% url 'admin:index' %}">{% trans 'Admin' %}</a>]
51 53 {% with ppd=posts_per_day|floatformat:2 %}
52 54 {% blocktrans %}Speed: {{ ppd }} posts per day{% endblocktrans %}
53 55 {% endwith %}
54 56 <a class="link" href="#top">{% trans 'Up' %}</a>
55 57 </div>
56 58
57 59 </body>
58 60 </html>
@@ -1,104 +1,102 b''
1 1 {% load i18n %}
2 2 {% load board %}
3 3 {% load cache %}
4 4
5 5 {% get_current_language as LANGUAGE_CODE %}
6 6
7 {% spaceless %}
8 {% cache 600 post post.id post.last_edit_time thread.archived bumpable truncated moderator LANGUAGE_CODE need_open_link %}
9 {% if thread.archived %}
10 <div class="post archive_post" id="{{ post.id }}">
11 {% elif bumpable %}
12 <div class="post" id="{{ post.id }}">
13 {% else %}
14 <div class="post dead_post" id="{{ post.id }}">
15 {% endif %}
7 {% if thread.archived %}
8 <div class="post archive_post" id="{{ post.id }}">
9 {% elif bumpable %}
10 <div class="post" id="{{ post.id }}">
11 {% else %}
12 <div class="post dead_post" id="{{ post.id }}">
13 {% endif %}
16 14
17 <div class="post-info">
18 <a class="post_id" href="{% post_object_url post thread=thread %}"
19 {% if not truncated and not thread.archived %}
20 onclick="javascript:addQuickReply('{{ post.id }}'); return false;"
21 title="{% trans 'Quote' %}"
22 {% endif %}
23 >({{ post.id }}) </a>
24 <span class="title">{{ post.title }} </span>
25 <span class="pub_time">{{ post.pub_time }}</span>
26 {% if thread.archived %}
27 — {{ thread.bump_time }}
15 <div class="post-info">
16 <a class="post_id" href="{% post_object_url post thread=thread %}"
17 {% if not truncated and not thread.archived %}
18 onclick="javascript:addQuickReply('{{ post.id }}'); return false;"
19 title="{% trans 'Quote' %}" {% endif %}>({{ post.id }})</a>
20 <span class="title">{{ post.title }}</span>
21 <span class="pub_time">{{ post.pub_time }}</span>
22 {% comment %}
23 Thread death time needs to be shown only if the thread is alredy archived
24 and this is an opening post (thread death time) or a post for popup
25 (we don't see OP here so we show the death time in the post itself).
26 {% endcomment %}
27 {% if thread.archived %}
28 {% if is_opening %}
29 — {{ thread.bump_time }}
30 {% endif %}
31 {% endif %}
32 {% if is_opening and need_open_link %}
33 {% if thread.archived %}
34 [<a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>]
35 {% else %}
36 [<a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>]
37 {% endif %}
38 {% endif %}
39
40 {% if post.global_id %}
41 <a class="global-id" href="
42 {% url 'post_sync_data' post.id %}"> [RAW] </a>
43 {% endif %}
44
45 {% if moderator %}
46 <span class="moderator_info">
47 [<a href="{% url 'admin:boards_post_change' post.id %}">{% trans 'Edit' %}</a>]
48 {% if is_opening %}
49 [<a href="{% url 'admin:boards_thread_change' thread.id %}">{% trans 'Edit thread' %}</a>]
28 50 {% endif %}
29 {% if is_opening and need_open_link %}
30 {% if thread.archived %}
31 [<a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>]
32 {% else %}
33 [<a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>]
34 {% endif %}
35 {% endif %}
36
37 {% if post.global_id %}
38 <a class="global-id" href="
39 {% url 'post_sync_data' post.id %}"> [RAW] </a>
40 {% endif %}
41
42 {% if moderator %}
43 <span class="moderator_info">
44 [<a href="{% url 'post_admin' post_id=post.id %}"
45 >{% trans 'Edit' %}</a>]
46 [<a href="{% url 'delete' post_id=post.id %}"
47 >{% trans 'Delete' %}</a>]
48 ({{ post.poster_ip }})
49 [<a href="{% url 'ban' post_id=post.id %}?next={{ request.path }}"
50 >{% trans 'Ban IP' %}</a>]
51 </span>
52 {% endif %}
51 </span>
52 {% endif %}
53 </div>
54 {% comment %}
55 Post images. Currently only 1 image can be posted and shown, but post model
56 supports multiple.
57 {% endcomment %}
58 {% if post.images.exists %}
59 {% with post.images.all.0 as image %}
60 {% autoescape off %}
61 {{ image.get_view }}
62 {% endautoescape %}
63 {% endwith %}
64 {% endif %}
65 {% comment %}
66 Post message (text)
67 {% endcomment %}
68 <div class="message">
69 {% autoescape off %}
70 {% if truncated %}
71 {{ post.get_text|truncatewords_html:50 }}
72 {% else %}
73 {{ post.get_text }}
74 {% endif %}
75 {% endautoescape %}
76 {% if post.is_referenced %}
77 <div class="refmap">
78 {% autoescape off %}
79 {% trans "Replies" %}: {{ post.refmap }}
80 {% endautoescape %}
53 81 </div>
54 {% if post.images.exists %}
55 {% with post.images.all.0 as image %}
56 <div class="image">
57 <a
58 class="thumb"
59 href="{{ image.image.url }}"><img
60 src="{{ image.image.url_200x150 }}"
61 alt="{{ post.id }}"
62 width="{{ image.pre_width }}"
63 height="{{ image.pre_height }}"
64 data-width="{{ image.width }}"
65 data-height="{{ image.height }}"/>
66 </a>
67 </div>
68 {% endwith %}
82 {% endif %}
83 </div>
84 {% comment %}
85 Thread metadata: counters, tags etc
86 {% endcomment %}
87 {% if is_opening %}
88 <div class="metadata">
89 {% if is_opening and need_open_link %}
90 {{ thread.get_reply_count }} {% trans 'messages' %},
91 {{ thread.get_images_count }} {% trans 'images' %}.
69 92 {% endif %}
70 <div class="message">
71 {% autoescape off %}
72 {% if truncated %}
73 {{ post.text.rendered|truncatewords_html:50 }}
74 {% else %}
75 {{ post.text.rendered }}
76 {% endif %}
77 {% endautoescape %}
78 {% if post.is_referenced %}
79 <div class="refmap">
80 {% autoescape off %}
81 {% trans "Replies" %}: {{ post.refmap }}
82 {% endautoescape %}
83 </div>
84 {% endif %}
85 </div>
86 {% endcache %}
87 {% if is_opening %}
88 {% cache 600 post_thread thread.id thread.last_edit_time LANGUAGE_CODE need_open_link %}
89 <div class="metadata">
90 {% if is_opening and need_open_link %}
91 {{ thread.get_reply_count }} {% trans 'messages' %},
92 {{ thread.get_images_count }} {% trans 'images' %}.
93 {% endif %}
94 <span class="tags">
95 {% for tag in thread.get_tags %}
96 <a class="tag" href="{% url 'tag' tag.name %}">
97 #{{ tag.name }}</a>{% if not forloop.last %},{% endif %}
98 {% endfor %}
99 </span>
100 </div>
101 {% endcache %}
102 {% endif %}
103 </div>
104 {% endspaceless %}
93 <span class="tags">
94 {% for tag in thread.get_tags %}
95 {% autoescape off %}
96 {{ tag.get_view }}{% if not forloop.last %},{% endif %}
97 {% endautoescape %}
98 {% endfor %}
99 </span>
100 </div>
101 {% endif %}
102 </div>
@@ -1,197 +1,202 b''
1 1 {% extends "boards/base.html" %}
2 2
3 3 {% load i18n %}
4 4 {% load cache %}
5 5 {% load board %}
6 6 {% load static %}
7 7
8 8 {% block head %}
9 9 {% if tag %}
10 10 <title>{{ tag.name }} - {{ site_name }}</title>
11 11 {% else %}
12 12 <title>{{ site_name }}</title>
13 13 {% endif %}
14 14
15 15 {% if current_page.has_previous %}
16 16 <link rel="prev" href="
17 17 {% if tag %}
18 {% url "tag" tag_name=tag page=current_page.previous_page_number %}
18 {% url "tag" tag_name=tag.name page=current_page.previous_page_number %}
19 19 {% elif archived %}
20 20 {% url "archive" page=current_page.previous_page_number %}
21 21 {% else %}
22 22 {% url "index" page=current_page.previous_page_number %}
23 23 {% endif %}
24 24 " />
25 25 {% endif %}
26 26 {% if current_page.has_next %}
27 27 <link rel="next" href="
28 28 {% if tag %}
29 {% url "tag" tag_name=tag page=current_page.next_page_number %}
29 {% url "tag" tag_name=tag.name page=current_page.next_page_number %}
30 30 {% elif archived %}
31 31 {% url "archive" page=current_page.next_page_number %}
32 32 {% else %}
33 33 {% url "index" page=current_page.next_page_number %}
34 34 {% endif %}
35 35 " />
36 36 {% endif %}
37 37
38 38 {% endblock %}
39 39
40 40 {% block content %}
41 41
42 42 {% get_current_language as LANGUAGE_CODE %}
43 43
44 44 {% if tag %}
45 45 <div class="tag_info">
46 46 <h2>
47 47 {% if tag in fav_tags %}
48 48 <a href="{% url 'tag' tag.name %}?method=unsubscribe&next={{ request.path }}"
49 class="fav"></a>
49 class="fav" rel="nofollow"></a>
50 50 {% else %}
51 51 <a href="{% url 'tag' tag.name %}?method=subscribe&next={{ request.path }}"
52 class="not_fav"></a>
52 class="not_fav" rel="nofollow"></a>
53 53 {% endif %}
54 54 {% if tag in hidden_tags %}
55 55 <a href="{% url 'tag' tag.name %}?method=unhide&next={{ request.path }}"
56 56 title="{% trans 'Show tag' %}"
57 class="fav">H</a>
57 class="fav" rel="nofollow">H</a>
58 58 {% else %}
59 59 <a href="{% url 'tag' tag.name %}?method=hide&next={{ request.path }}"
60 60 title="{% trans 'Hide tag' %}"
61 class="not_fav">H</a>
61 class="not_fav" rel="nofollow">H</a>
62 62 {% endif %}
63 #{{ tag.name }}
63 {% autoescape off %}
64 {{ tag.get_view }}
65 {% endautoescape %}
66 {% if moderator %}
67 [<a href="{% url 'admin:boards_tag_change' tag.id %}"$>{% trans 'Edit tag' %}</a>]
68 {% endif %}
64 69 </h2>
65 70 </div>
66 71 {% endif %}
67 72
68 73 {% if threads %}
69 74 {% if current_page.has_previous %}
70 75 <div class="page_link">
71 76 <a href="
72 77 {% if tag %}
73 {% url "tag" tag_name=tag page=current_page.previous_page_number %}
78 {% url "tag" tag_name=tag.name page=current_page.previous_page_number %}
74 79 {% elif archived %}
75 80 {% url "archive" page=current_page.previous_page_number %}
76 81 {% else %}
77 82 {% url "index" page=current_page.previous_page_number %}
78 83 {% endif %}
79 84 ">{% trans "Previous page" %}</a>
80 85 </div>
81 86 {% endif %}
82 87
83 88 {% for thread in threads %}
84 89 {% cache 600 thread_short thread.id thread.last_edit_time moderator LANGUAGE_CODE %}
85 90 <div class="thread">
86 91 {% with can_bump=thread.can_bump %}
87 92 {% post_view thread.get_opening_post moderator is_opening=True thread=thread can_bump=can_bump truncated=True need_open_link=True %}
88 93 {% if not thread.archived %}
89 94 {% with last_replies=thread.get_last_replies %}
90 95 {% if last_replies %}
91 96 {% if thread.get_skipped_replies_count %}
92 97 <div class="skipped_replies">
93 98 <a href="{% url 'thread' thread.get_opening_post.id %}">
94 99 {% blocktrans with count=thread.get_skipped_replies_count %}Skipped {{ count }} replies. Open thread to see all replies.{% endblocktrans %}
95 100 </a>
96 101 </div>
97 102 {% endif %}
98 103 <div class="last-replies">
99 104 {% for post in last_replies %}
100 105 {% post_view post moderator=moderator is_opening=False thread=thread can_bump=can_bump truncated=True %}
101 106 {% endfor %}
102 107 </div>
103 108 {% endif %}
104 109 {% endwith %}
105 110 {% endif %}
106 111 {% endwith %}
107 112 </div>
108 113 {% endcache %}
109 114 {% endfor %}
110 115
111 116 {% if current_page.has_next %}
112 117 <div class="page_link">
113 118 <a href="
114 119 {% if tag %}
115 {% url "tag" tag_name=tag page=current_page.next_page_number %}
120 {% url "tag" tag_name=tag.name page=current_page.next_page_number %}
116 121 {% elif archived %}
117 122 {% url "archive" page=current_page.next_page_number %}
118 123 {% else %}
119 124 {% url "index" page=current_page.next_page_number %}
120 125 {% endif %}
121 126 ">{% trans "Next page" %}</a>
122 127 </div>
123 128 {% endif %}
124 129 {% else %}
125 130 <div class="post">
126 131 {% trans 'No threads exist. Create the first one!' %}</div>
127 132 {% endif %}
128 133
129 134 <div class="post-form-w">
130 135 <script src="{% static 'js/panel.js' %}"></script>
131 136 <div class="post-form">
132 137 <div class="form-title">{% trans "Create new thread" %}</div>
133 138 <div class="swappable-form-full">
134 139 <form enctype="multipart/form-data" method="post">{% csrf_token %}
135 140 {{ form.as_div }}
136 141 <div class="form-submit">
137 142 <input type="submit" value="{% trans "Post" %}"/>
138 143 </div>
139 144 </form>
140 145 </div>
141 146 <div>
142 147 {% trans 'Tags must be delimited by spaces. Text or image is required.' %}
143 148 </div>
144 149 <div><a href="{% url "staticpage" name="help" %}">
145 150 {% trans 'Text syntax' %}</a></div>
146 151 </div>
147 152 </div>
148 153
149 154 <script src="{% static 'js/form.js' %}"></script>
150 155
151 156 {% endblock %}
152 157
153 158 {% block metapanel %}
154 159
155 160 <span class="metapanel">
156 161 <b><a href="{% url "authors" %}">{{ site_name }}</a> {{ version }}</b>
157 162 {% trans "Pages:" %}
158 163 <a href="
159 164 {% if tag %}
160 {% url "tag" tag_name=tag page=paginator.page_range|first %}
165 {% url "tag" tag_name=tag.name page=paginator.page_range|first %}
161 166 {% elif archived %}
162 167 {% url "archive" page=paginator.page_range|first %}
163 168 {% else %}
164 169 {% url "index" page=paginator.page_range|first %}
165 170 {% endif %}
166 171 ">&lt;&lt;</a>
167 172 [
168 173 {% for page in paginator.center_range %}
169 174 <a
170 175 {% ifequal page current_page.number %}
171 176 class="current_page"
172 177 {% endifequal %}
173 178 href="
174 179 {% if tag %}
175 {% url "tag" tag_name=tag page=page %}
180 {% url "tag" tag_name=tag.name page=page %}
176 181 {% elif archived %}
177 182 {% url "archive" page=page %}
178 183 {% else %}
179 184 {% url "index" page=page %}
180 185 {% endif %}
181 186 ">{{ page }}</a>
182 187 {% if not forloop.last %},{% endif %}
183 188 {% endfor %}
184 189 ]
185 190 <a href="
186 191 {% if tag %}
187 {% url "tag" tag_name=tag page=paginator.page_range|last %}
192 {% url "tag" tag_name=tag.name page=paginator.page_range|last %}
188 193 {% elif archived %}
189 194 {% url "archive" page=paginator.page_range|last %}
190 195 {% else %}
191 196 {% url "index" page=paginator.page_range|last %}
192 197 {% endif %}
193 198 ">&gt;&gt;</a>
194 199 [<a href="rss/">RSS</a>]
195 200 </span>
196 201
197 202 {% endblock %}
@@ -1,15 +1,15 b''
1 1 {% load i18n %}
2 2
3 3 {% if obj.images.exists %}
4 4 <img src="{{ obj.get_first_image.image.url_200x150 }}"
5 5 alt="{% trans 'Post image' %}" />
6 6 {% endif %}
7 {{ obj.text.rendered|safe }}
7 {{ obj.get_text|safe }}
8 8 {% if obj.tags.all %}
9 9 <p>
10 10 {% trans 'Tags' %}:
11 11 {% for tag in obj.tags.all %}
12 12 {{ tag.name }}
13 13 {% endfor %}
14 14 </p>
15 15 {% endif %} No newline at end of file
@@ -1,3 +1,5 b''
1 1 <div class="post">
2 <a class="tag" href="{% url 'tag' tag_name=tag.name %}">#{{ tag.name }}</a>
3 </div> No newline at end of file
2 {% autoescape off %}
3 {{ tag.get_view }}
4 {% endautoescape %}
5 </div>
@@ -1,27 +1,28 b''
1 1 {% extends "boards/base.html" %}
2 2
3 3 {% load i18n %}
4 4 {% load cache %}
5 5
6 6 {% block head %}
7 7 <title>Neboard - {% trans "Tags" %}</title>
8 8 {% endblock %}
9 9
10 10 {% block content %}
11 11
12 12 {% cache 600 all_tags_list %}
13 13 <div class="post">
14 14 {% if all_tags %}
15 15 {% for tag in all_tags %}
16 16 <div class="tag_item">
17 <a class="tag" href="{% url 'tag' tag.name %}">
18 #{{ tag.name }}</a>
17 {% autoescape off %}
18 {{ tag.get_view }}
19 {% endautoescape %}
19 20 </div>
20 21 {% endfor %}
21 22 {% else %}
22 23 {% trans 'No tags found.' %}
23 24 {% endif %}
24 25 </div>
25 26 {% endcache %}
26 27
27 28 {% endblock %}
@@ -1,95 +1,96 b''
1 1 {% extends "boards/base.html" %}
2 2
3 3 {% load i18n %}
4 4 {% load cache %}
5 5 {% load static from staticfiles %}
6 6 {% load board %}
7 7
8 8 {% block head %}
9 9 <title>{{ opening_post.get_title|striptags|truncatewords:10 }}
10 10 - {{ site_name }}</title>
11 11 {% endblock %}
12 12
13 13 {% block content %}
14 {% spaceless %}
15 14 {% get_current_language as LANGUAGE_CODE %}
16 15
17 16 {% cache 600 thread_view thread.id thread.last_edit_time moderator LANGUAGE_CODE %}
18 17
19 18 <div class="image-mode-tab">
20 19 <a class="current_mode" href="{% url 'thread' opening_post.id %}">{% trans 'Normal mode' %}</a>,
21 20 <a href="{% url 'thread_mode' opening_post.id 'gallery' %}">{% trans 'Gallery mode' %}</a>
22 21 </div>
23 22
24 23 {% if bumpable %}
25 24 <div class="bar-bg">
26 25 <div class="bar-value" style="width:{{ bumplimit_progress }}%" id="bumplimit_progress">
27 26 </div>
28 27 <div class="bar-text">
29 28 <span id="left_to_limit">{{ posts_left }}</span> {% trans 'posts to bumplimit' %}
30 29 </div>
31 30 </div>
32 31 {% endif %}
33 32
34 33 <div class="thread">
35 34 {% with can_bump=thread.can_bump %}
36 35 {% for post in thread.get_replies %}
37 {% if forloop.first %}
38 {% post_view post moderator=moderator is_opening=True thread=thread can_bump=can_bump opening_post_id=opening_post.id %}
39 {% else %}
40 {% post_view post moderator=moderator is_opening=False thread=thread can_bump=can_bump opening_post_id=opening_post.id %}
41 {% endif %}
36 {% with is_opening=forloop.first %}
37 {% post_view post moderator=moderator is_opening=is_opening thread=thread bumpable=can_bump opening_post_id=opening_post.id %}
38 {% endwith %}
42 39 {% endfor %}
43 40 {% endwith %}
44 41 </div>
45 42
46 43 {% if not thread.archived %}
47
48 <div class="post-form-w" id="form">
49 <script src="{% static 'js/panel.js' %}"></script>
50 <div class="form-title">{% trans "Reply to thread" %} #{{ opening_post.id }}</div>
51 <div class="post-form" id="compact-form">
52 <div class="swappable-form-full">
53 <form enctype="multipart/form-data" method="post"
54 >{% csrf_token %}
44 <div class="post-form-w" id="form">
45 <script src="{% static 'js/panel.js' %}"></script>
46 <div class="form-title">{% trans "Reply to thread" %} #{{ opening_post.id }}</div>
47 <div class="post-form" id="compact-form">
48 <div class="swappable-form-full">
49 <form enctype="multipart/form-data" method="post"
50 >{% csrf_token %}
55 51 <div class="compact-form-text"></div>
56 {{ form.as_div }}
57 <div class="form-submit">
58 <input type="submit" value="{% trans "Post" %}"/>
59 </div>
60 </form>
52 {{ form.as_div }}
53 <div class="form-submit">
54 <input type="submit" value="{% trans "Post" %}"/>
55 </div>
56 </form>
57 </div>
58 <a onclick="swapForm(); return false;" href="#">
59 {% trans 'Switch mode' %}
60 </a>
61 <div><a href="{% url "staticpage" name="help" %}">
62 {% trans 'Text syntax' %}</a></div>
61 63 </div>
62 <a onclick="swapForm(); return false;" href="#">
63 {% trans 'Switch mode' %}
64 </a>
65 <div><a href="{% url "staticpage" name="help" %}">
66 {% trans 'Text syntax' %}</a></div>
67 64 </div>
68 </div>
69 65
70 <script src="{% static 'js/jquery.form.min.js' %}"></script>
71 <script src="{% static 'js/thread_update.js' %}"></script>
66 <script src="{% static 'js/jquery.form.min.js' %}"></script>
67 <script src="{% static 'js/thread_update.js' %}"></script>
68 <script src="{% static 'js/3party/centrifuge.js' %}"></script>
72 69 {% endif %}
73 70
74 71 <script src="{% static 'js/form.js' %}"></script>
75 72 <script src="{% static 'js/thread.js' %}"></script>
76 73
77 74 {% endcache %}
78
79 {% endspaceless %}
80 75 {% endblock %}
81 76
82 77 {% block metapanel %}
83 78
84 79 {% get_current_language as LANGUAGE_CODE %}
85 80
86 <span class="metapanel" data-last-update="{{ last_update }}">
81 <span class="metapanel"
82 data-last-update="{{ last_update }}"
83 data-ws-token="{{ ws_token }}"
84 data-ws-project="{{ ws_project }}"
85 data-ws-host="{{ ws_host }}"
86 data-ws-port="{{ ws_port }}">
87 87 {% cache 600 thread_meta thread.last_edit_time moderator LANGUAGE_CODE %}
88 <span id="autoupdate">[-]</span>
88 89 <span id="reply-count">{{ thread.get_reply_count }}</span>/{{ max_replies }} {% trans 'messages' %},
89 90 <span id="image-count">{{ thread.get_images_count }}</span> {% trans 'images' %}.
90 91 {% trans 'Last update: ' %}<span id="last-update">{{ thread.last_edit_time }}</span>
91 92 [<a href="rss/">RSS</a>]
92 93 {% endcache %}
93 94 </span>
94 95
95 96 {% endblock %}
@@ -1,38 +1,38 b''
1 1 {% extends 'boards/base.html' %}
2 2
3 3 {% load board %}
4 4 {% load i18n %}
5 5
6 6 {% block content %}
7 7 <div class="post-form-w">
8 8 <div class="post-form">
9 9 <h3>{% trans 'Search' %}</h3>
10 10 <form method="get" action="">
11 11 {{ form.as_div }}
12 12 <div class="form-submit">
13 13 <input type="submit" value="{% trans 'Search' %}">
14 14 </div>
15 15 </form>
16 16 </div>
17 17 </div>
18 18
19 19 {% if page %}
20 20 {% if page.has_previous %}
21 21 <div class="page_link">
22 22 <a href="?query={{ query }}&amp;page={{ page.previous_page_number }}">{% trans "Previous page" %}
23 23 </a>
24 24 </div>
25 25 {% endif %}
26 26
27 27 {% for result in page.object_list %}
28 {{ result.object.get_view }}
28 {{ result.object.get_search_view }}
29 29 {% endfor %}
30 30
31 31 {% if page.has_next %}
32 32 <div class="page_link">
33 33 <a href="?query={{ query }}&amp;page={{ page.next_page_number }}">{% trans "Next page" %}
34 34 </a>
35 35 </div>
36 36 {% endif %}
37 37 {% endif %}
38 {% endblock %} No newline at end of file
38 {% endblock %}
@@ -1,55 +1,56 b''
1 1 from django.test import TestCase, Client
2 2 import time
3 3 from boards import settings
4 from boards.models import Post
4 from boards.models import Post, Tag
5 5 import neboard
6 6
7 7
8 8 TEST_TAG = 'test_tag'
9 9
10 10 PAGE_404 = 'boards/404.html'
11 11
12 12 TEST_TEXT = 'test text'
13 13
14 14 NEW_THREAD_PAGE = '/'
15 15 THREAD_PAGE_ONE = '/thread/1/'
16 16 HTTP_CODE_REDIRECT = 302
17 17
18 18
19 19 class FormTest(TestCase):
20 20 def test_post_validation(self):
21 21 client = Client()
22 22
23 23 valid_tags = 'tag1 tag_2 тег_3'
24 24 invalid_tags = '$%_356 ---'
25 Tag.objects.create(name='tag1', required=True)
25 26
26 27 response = client.post(NEW_THREAD_PAGE, {'title': 'test title',
27 28 'text': TEST_TEXT,
28 29 'tags': valid_tags})
29 30 self.assertEqual(response.status_code, HTTP_CODE_REDIRECT,
30 31 msg='Posting new message failed: got code ' +
31 32 str(response.status_code))
32 33
33 34 self.assertEqual(1, Post.objects.count(),
34 35 msg='No posts were created')
35 36
36 37 client.post(NEW_THREAD_PAGE, {'text': TEST_TEXT,
37 38 'tags': invalid_tags})
38 39 self.assertEqual(1, Post.objects.count(), msg='The validation passed '
39 40 'where it should fail')
40 41
41 42 # Change posting delay so we don't have to wait for 30 seconds or more
42 43 old_posting_delay = neboard.settings.POSTING_DELAY
43 44 # Wait fot the posting delay or we won't be able to post
44 45 neboard.settings.POSTING_DELAY = 1
45 46 time.sleep(neboard.settings.POSTING_DELAY + 1)
46 47 response = client.post(THREAD_PAGE_ONE, {'text': TEST_TEXT,
47 48 'tags': valid_tags})
48 49 self.assertEqual(HTTP_CODE_REDIRECT, response.status_code,
49 50 msg='Posting new message failed: got code ' +
50 51 str(response.status_code))
51 52 # Restore posting delay
52 53 settings.POSTING_DELAY = old_posting_delay
53 54
54 55 self.assertEqual(2, Post.objects.count(),
55 56 msg='No posts were created')
@@ -1,56 +1,52 b''
1 1 from django.test import TestCase, Client
2 2 from boards.models import Tag, Post
3 3
4 4 TEST_TEXT = 'test'
5 5
6 6 NEW_THREAD_PAGE = '/'
7 7 THREAD_PAGE_ONE = '/thread/1/'
8 8 THREAD_PAGE = '/thread/'
9 9 TAG_PAGE = '/tag/'
10 HTTP_CODE_REDIRECT = 302
10 HTTP_CODE_REDIRECT = 301
11 11 HTTP_CODE_OK = 200
12 12 HTTP_CODE_NOT_FOUND = 404
13 13
14 PAGE_404 = 'boards/404.html'
15
16 14
17 15 class PagesTest(TestCase):
18 16
19 17 def test_404(self):
20 18 """Test receiving error 404 when opening a non-existent page"""
21 19
22 20 tag_name = 'test_tag'
23 21 tag = Tag.objects.create(name=tag_name)
24 22 client = Client()
25 23
26 24 Post.objects.create_post('title', TEST_TEXT, tags=[tag])
27 25
28 26 existing_post_id = Post.objects.all()[0].id
29 27 response_existing = client.get(THREAD_PAGE + str(existing_post_id) +
30 28 '/')
31 29 self.assertEqual(HTTP_CODE_OK, response_existing.status_code,
32 30 'Cannot open existing thread')
33 31
34 32 response_not_existing = client.get(THREAD_PAGE + str(
35 33 existing_post_id + 1) + '/')
36 self.assertEqual(PAGE_404, response_not_existing.templates[0].name,
34 self.assertEqual(HTTP_CODE_NOT_FOUND, response_not_existing.status_code,
37 35 'Not existing thread is opened')
38 36
39 37 response_existing = client.get(TAG_PAGE + tag_name + '/')
40 38 self.assertEqual(HTTP_CODE_OK,
41 39 response_existing.status_code,
42 40 'Cannot open existing tag')
43 41
44 42 response_not_existing = client.get(TAG_PAGE + 'not_tag' + '/')
45 self.assertEqual(PAGE_404,
46 response_not_existing.templates[0].name,
43 self.assertEqual(HTTP_CODE_NOT_FOUND, response_not_existing.status_code,
47 44 'Not existing tag is opened')
48 45
49 46 reply_id = Post.objects.create_post('', TEST_TEXT,
50 47 thread=Post.objects.all()[0]
51 48 .get_thread())
52 49 response_not_existing = client.get(THREAD_PAGE + str(
53 50 reply_id) + '/')
54 self.assertEqual(PAGE_404,
55 response_not_existing.templates[0].name,
51 self.assertEqual(HTTP_CODE_REDIRECT, response_not_existing.status_code,
56 52 'Reply is opened as a thread')
@@ -1,142 +1,163 b''
1 1 from django.core.paginator import Paginator
2 2 from django.test import TestCase
3 3 from boards import settings
4 4 from boards.models import Tag, Post, Thread, KeyPair
5 5
6 6
7 7 class PostTests(TestCase):
8 8
9 9 def _create_post(self):
10 10 tag = Tag.objects.create(name='test_tag')
11 11 return Post.objects.create_post(title='title', text='text',
12 12 tags=[tag])
13 13
14 14 def test_post_add(self):
15 15 """Test adding post"""
16 16
17 17 post = self._create_post()
18 18
19 19 self.assertIsNotNone(post, 'No post was created.')
20 20 self.assertEqual('test_tag', post.get_thread().tags.all()[0].name,
21 21 'No tags were added to the post.')
22 22
23 23 def test_delete_post(self):
24 24 """Test post deletion"""
25 25
26 26 post = self._create_post()
27 27 post_id = post.id
28 28
29 Post.objects.delete_post(post)
29 post.delete()
30 30
31 31 self.assertFalse(Post.objects.filter(id=post_id).exists())
32 32
33 33 def test_delete_thread(self):
34 34 """Test thread deletion"""
35 35
36 36 opening_post = self._create_post()
37 37 thread = opening_post.get_thread()
38 38 reply = Post.objects.create_post("", "", thread=thread)
39 39
40 thread.delete()
40 opening_post.delete()
41 41
42 self.assertFalse(Post.objects.filter(id=reply.id).exists())
42 self.assertFalse(Post.objects.filter(id=reply.id).exists(),
43 'Reply was not deleted with the thread.')
44 self.assertFalse(Post.objects.filter(id=opening_post.id).exists(),
45 'Opening post was not deleted with the thread.')
43 46
44 47 def test_post_to_thread(self):
45 48 """Test adding post to a thread"""
46 49
47 50 op = self._create_post()
48 51 post = Post.objects.create_post("", "", thread=op.get_thread())
49 52
50 53 self.assertIsNotNone(post, 'Reply to thread wasn\'t created')
51 54 self.assertEqual(op.get_thread().last_edit_time, post.pub_time,
52 55 'Post\'s create time doesn\'t match thread last edit'
53 56 ' time')
54 57
55 58 def test_delete_posts_by_ip(self):
56 59 """Test deleting posts with the given ip"""
57 60
58 61 post = self._create_post()
59 62 post_id = post.id
60 63
61 64 Post.objects.delete_posts_by_ip('0.0.0.0')
62 65
63 66 self.assertFalse(Post.objects.filter(id=post_id).exists())
64 67
65 68 def test_get_thread(self):
66 69 """Test getting all posts of a thread"""
67 70
68 71 opening_post = self._create_post()
69 72
70 73 for i in range(2):
71 74 Post.objects.create_post('title', 'text',
72 75 thread=opening_post.get_thread())
73 76
74 77 thread = opening_post.get_thread()
75 78
76 79 self.assertEqual(3, thread.replies.count())
77 80
78 81 def test_create_post_with_tag(self):
79 82 """Test adding tag to post"""
80 83
81 84 tag = Tag.objects.create(name='test_tag')
82 85 post = Post.objects.create_post(title='title', text='text', tags=[tag])
83 86
84 87 thread = post.get_thread()
85 88 self.assertIsNotNone(post, 'Post not created')
86 89 self.assertTrue(tag in thread.tags.all(), 'Tag not added to thread')
87 self.assertTrue(thread in tag.threads.all(), 'Thread not added to tag')
88 90
89 91 def test_thread_max_count(self):
90 92 """Test deletion of old posts when the max thread count is reached"""
91 93
92 94 for i in range(settings.MAX_THREAD_COUNT + 1):
93 95 self._create_post()
94 96
95 97 self.assertEqual(settings.MAX_THREAD_COUNT,
96 98 len(Thread.objects.filter(archived=False)))
97 99
98 100 def test_pages(self):
99 101 """Test that the thread list is properly split into pages"""
100 102
101 103 for i in range(settings.MAX_THREAD_COUNT):
102 104 self._create_post()
103 105
104 106 all_threads = Thread.objects.filter(archived=False)
105 107
106 108 paginator = Paginator(Thread.objects.filter(archived=False),
107 109 settings.THREADS_PER_PAGE)
108 110 posts_in_second_page = paginator.page(2).object_list
109 111 first_post = posts_in_second_page[0]
110 112
111 113 self.assertEqual(all_threads[settings.THREADS_PER_PAGE].id,
112 114 first_post.id)
113 115
114 116 def test_reflinks(self):
115 117 """
116 118 Tests that reflinks are parsed within post and connecting replies
117 119 to the replied posts.
118 120
119 121 Local reflink example: [post]123[/post]
120 122 Global reflink example: [post]key_type::key::123[/post]
121 123 """
122 124
123 125 key = KeyPair.objects.generate_key(primary=True)
124 126
125 127 tag = Tag.objects.create(name='test_tag')
126 128
127 129 post = Post.objects.create_post(title='', text='', tags=[tag])
128 130 post_local_reflink = Post.objects.create_post(title='',
129 131 text='[post]%d[/post]' % post.id, thread=post.get_thread())
130 132
131 133 self.assertTrue(post_local_reflink in post.referenced_posts.all(),
132 134 'Local reflink not connecting posts.')
133 135
134 136 post_global_reflink = Post.objects.create_post(title='',
135 137 text='[post]%s::%s::%d[/post]' % (
136 138 post.global_id.key_type, post.global_id.key, post.id),
137 139 thread=post.get_thread())
138 140
139 141 self.assertTrue(post_global_reflink in post.referenced_posts.all(),
140 142 'Global reflink not connecting posts.')
141 143
142 # TODO Check that links are parsed into the rendered text
144 def test_thread_replies(self):
145 """
146 Tests that the replies can be queried from a thread in all possible
147 ways.
148 """
149
150 tag = Tag.objects.create(name='test_tag')
151 opening_post = Post.objects.create_post(title='title', text='text',
152 tags=[tag])
153 thread = opening_post.get_thread()
154
155 reply1 = Post.objects.create_post(title='title', text='text', thread=thread)
156 reply2 = Post.objects.create_post(title='title', text='text', thread=thread)
157
158 replies = thread.get_replies()
159 self.assertTrue(len(replies) > 0, 'No replies found for thread.')
160
161 replies = thread.get_replies(view_fields_only=True)
162 self.assertTrue(len(replies) > 0,
163 'No replies found for thread with view fields only.')
@@ -1,38 +1,46 b''
1 1 import logging
2 2 from django.core.urlresolvers import reverse, NoReverseMatch
3 3 from django.test import TestCase, Client
4 4 from boards import urls
5 5
6 6
7 7 logger = logging.getLogger(__name__)
8 8
9 9 HTTP_CODE_OK = 200
10 10
11 EXCLUDED_VIEWS = {
12 'banned',
13 }
14
11 15
12 16 class ViewTest(TestCase):
13 17
14 18 def test_all_views(self):
15 19 """
16 20 Try opening all views defined in ulrs.py that don't need additional
17 21 parameters
18 22 """
19 23
20 24 client = Client()
21 25 for url in urls.urlpatterns:
22 26 try:
23 27 view_name = url.name
28 if view_name in EXCLUDED_VIEWS:
29 logger.debug('View {} is excluded.'.format(view_name))
30 continue
31
24 32 logger.debug('Testing view %s' % view_name)
25 33
26 34 try:
27 35 response = client.get(reverse(view_name))
28 36
29 37 self.assertEqual(HTTP_CODE_OK, response.status_code,
30 '%s view not opened' % view_name)
38 'View not opened: {}'.format(view_name))
31 39 except NoReverseMatch:
32 40 # This view just needs additional arguments
33 41 pass
34 42 except Exception as e:
35 43 self.fail('Got exception %s at %s view' % (e, view_name))
36 44 except AttributeError:
37 45 # This is normal, some views do not have names
38 46 pass
@@ -1,219 +1,215 b''
1 1 # -*- encoding: utf-8 -*-
2 2 """
3 3 django-thumbs by Antonio Melé
4 4 http://django.es
5 5 """
6 6 from django.core.files.images import ImageFile
7 7 from django.db.models import ImageField
8 8 from django.db.models.fields.files import ImageFieldFile
9 9 from PIL import Image
10 10 from django.core.files.base import ContentFile
11 11 import io
12 12
13 13
14 14 def generate_thumb(img, thumb_size, format):
15 15 """
16 16 Generates a thumbnail image and returns a ContentFile object with the thumbnail
17 17
18 18 Parameters:
19 19 ===========
20 20 img File object
21 21
22 22 thumb_size desired thumbnail size, ie: (200,120)
23 23
24 24 format format of the original image ('jpeg','gif','png',...)
25 25 (this format will be used for the generated thumbnail, too)
26 26 """
27 27
28 28 img.seek(0) # see http://code.djangoproject.com/ticket/8222 for details
29 29 image = Image.open(img)
30 30
31 31 # get size
32 32 thumb_w, thumb_h = thumb_size
33 33 # If you want to generate a square thumbnail
34 34 if thumb_w == thumb_h:
35 35 # quad
36 36 xsize, ysize = image.size
37 37 # get minimum size
38 38 minsize = min(xsize, ysize)
39 39 # largest square possible in the image
40 40 xnewsize = (xsize - minsize) / 2
41 41 ynewsize = (ysize - minsize) / 2
42 42 # crop it
43 43 image2 = image.crop(
44 44 (xnewsize, ynewsize, xsize - xnewsize, ysize - ynewsize))
45 45 # load is necessary after crop
46 46 image2.load()
47 47 # thumbnail of the cropped image (with ANTIALIAS to make it look better)
48 48 image2.thumbnail(thumb_size, Image.ANTIALIAS)
49 49 else:
50 50 # not quad
51 51 image2 = image
52 52 image2.thumbnail(thumb_size, Image.ANTIALIAS)
53 53
54 54 output = io.BytesIO()
55 55 # PNG and GIF are the same, JPG is JPEG
56 56 if format.upper() == 'JPG':
57 57 format = 'JPEG'
58 58
59 59 image2.save(output, format)
60 60 return ContentFile(output.getvalue())
61 61
62 62
63 63 class ImageWithThumbsFieldFile(ImageFieldFile):
64 64 """
65 65 See ImageWithThumbsField for usage example
66 66 """
67 67
68 68 def __init__(self, *args, **kwargs):
69 69 super(ImageWithThumbsFieldFile, self).__init__(*args, **kwargs)
70 70 self.sizes = self.field.sizes
71 71
72 72 if self.sizes:
73 73 def get_size(self, size):
74 74 if not self:
75 75 return ''
76 76 else:
77 77 split = self.url.rsplit('.', 1)
78 78 thumb_url = '%s.%sx%s.%s' % (split[0], w, h, split[1])
79 79 return thumb_url
80 80
81 81 for size in self.sizes:
82 82 (w, h) = size
83 83 setattr(self, 'url_%sx%s' % (w, h), get_size(self, size))
84 84
85 85 def save(self, name, content, save=True):
86 86 super(ImageWithThumbsFieldFile, self).save(name, content, save)
87 87
88 88 if self.sizes:
89 89 for size in self.sizes:
90 90 (w, h) = size
91 91 split = self.name.rsplit('.', 1)
92 92 thumb_name = '%s.%sx%s.%s' % (split[0], w, h, split[1])
93 93
94 94 # you can use another thumbnailing function if you like
95 95 thumb_content = generate_thumb(content, size, split[1])
96 96
97 97 thumb_name_ = self.storage.save(thumb_name, thumb_content)
98 98
99 99 if not thumb_name == thumb_name_:
100 100 raise ValueError(
101 101 'There is already a file named %s' % thumb_name)
102 102
103 103 def delete(self, save=True):
104 104 name = self.name
105 105 super(ImageWithThumbsFieldFile, self).delete(save)
106 106 if self.sizes:
107 107 for size in self.sizes:
108 108 (w, h) = size
109 109 split = name.rsplit('.', 1)
110 110 thumb_name = '%s.%sx%s.%s' % (split[0], w, h, split[1])
111 111 try:
112 112 self.storage.delete(thumb_name)
113 113 except:
114 114 pass
115 115
116 116
117 117 class ImageWithThumbsField(ImageField):
118 118 attr_class = ImageWithThumbsFieldFile
119 119 """
120 120 Usage example:
121 121 ==============
122 122 photo = ImageWithThumbsField(upload_to='images', sizes=((125,125),(300,200),)
123 123
124 124 To retrieve image URL, exactly the same way as with ImageField:
125 125 my_object.photo.url
126 126 To retrieve thumbnails URL's just add the size to it:
127 127 my_object.photo.url_125x125
128 128 my_object.photo.url_300x200
129 129
130 130 Note: The 'sizes' attribute is not required. If you don't provide it,
131 131 ImageWithThumbsField will act as a normal ImageField
132 132
133 133 How it works:
134 134 =============
135 135 For each size in the 'sizes' atribute of the field it generates a
136 136 thumbnail with that size and stores it following this format:
137 137
138 138 available_filename.[width]x[height].extension
139 139
140 140 Where 'available_filename' is the available filename returned by the storage
141 141 backend for saving the original file.
142 142
143 143 Following the usage example above: For storing a file called "photo.jpg" it saves:
144 144 photo.jpg (original file)
145 145 photo.125x125.jpg (first thumbnail)
146 146 photo.300x200.jpg (second thumbnail)
147 147
148 148 With the default storage backend if photo.jpg already exists it will use these filenames:
149 149 photo_.jpg
150 150 photo_.125x125.jpg
151 151 photo_.300x200.jpg
152 152
153 153 Note: django-thumbs assumes that if filename "any_filename.jpg" is available
154 154 filenames with this format "any_filename.[widht]x[height].jpg" will be available, too.
155 155
156 156 To do:
157 157 ======
158 158 Add method to regenerate thubmnails
159 159
160 160
161 161 """
162 162
163 163 preview_width_field = None
164 164 preview_height_field = None
165 165
166 166 def __init__(self, verbose_name=None, name=None, width_field=None,
167 167 height_field=None, sizes=None,
168 168 preview_width_field=None, preview_height_field=None,
169 169 **kwargs):
170 170 self.verbose_name = verbose_name
171 171 self.name = name
172 172 self.width_field = width_field
173 173 self.height_field = height_field
174 174 self.sizes = sizes
175 175 super(ImageField, self).__init__(**kwargs)
176 176
177 177 if sizes is not None and len(sizes) == 1:
178 178 self.preview_width_field = preview_width_field
179 179 self.preview_height_field = preview_height_field
180 180
181 181 def update_dimension_fields(self, instance, force=False, *args, **kwargs):
182 182 """
183 183 Update original image dimension fields and thumb dimension fields
184 184 (only if 1 thumb size is defined)
185 185 """
186 186
187 187 super(ImageWithThumbsField, self).update_dimension_fields(instance,
188 188 force, *args,
189 189 **kwargs)
190 190 thumb_width_field = self.preview_width_field
191 191 thumb_height_field = self.preview_height_field
192 192
193 193 if thumb_width_field is None or thumb_height_field is None \
194 194 or len(self.sizes) != 1:
195 195 return
196 196
197 197 original_width = getattr(instance, self.width_field)
198 198 original_height = getattr(instance, self.height_field)
199 199
200 200 if original_width > 0 and original_height > 0:
201 201 thumb_width, thumb_height = self.sizes[0]
202 202
203 203 w_scale = float(thumb_width) / original_width
204 204 h_scale = float(thumb_height) / original_height
205 205 scale_ratio = min(w_scale, h_scale)
206 206
207 207 if scale_ratio >= 1:
208 208 thumb_width_ratio = original_width
209 209 thumb_height_ratio = original_height
210 210 else:
211 211 thumb_width_ratio = int(original_width * scale_ratio)
212 212 thumb_height_ratio = int(original_height * scale_ratio)
213 213
214 214 setattr(instance, thumb_width_field, thumb_width_ratio)
215 setattr(instance, thumb_height_field, thumb_height_ratio)
216
217
218 from south.modelsinspector import add_introspection_rules
219 add_introspection_rules([], ["^boards\.thumbs\.ImageWithThumbsField"])
215 setattr(instance, thumb_height_field, thumb_height_ratio) No newline at end of file
@@ -1,87 +1,79 b''
1 1 from django.conf.urls import patterns, url, include
2 2 from django.contrib import admin
3 3 from boards import views
4 4 from boards.rss import AllThreadsFeed, TagThreadsFeed, ThreadPostsFeed
5 5 from boards.views import api, tag_threads, all_threads, \
6 6 settings, all_tags
7 7 from boards.views.authors import AuthorsView
8 from boards.views.delete_post import DeletePostView
9 8 from boards.views.ban import BanUserView
10 9 from boards.views.search import BoardSearchView
11 10 from boards.views.static import StaticPageView
12 from boards.views.post_admin import PostAdminView
13 11 from boards.views.preview import PostPreviewView
14 12 from boards.views.sync import get_post_sync_data
15 13
16 14 js_info_dict = {
17 15 'packages': ('boards',),
18 16 }
19 17
20 18 urlpatterns = patterns('',
21 19 # /boards/
22 20 url(r'^$', all_threads.AllThreadsView.as_view(), name='index'),
23 21 # /boards/page/
24 22 url(r'^page/(?P<page>\w+)/$', all_threads.AllThreadsView.as_view(),
25 23 name='index'),
26 24
27 25 # /boards/tag/tag_name/
28 26 url(r'^tag/(?P<tag_name>\w+)/$', tag_threads.TagView.as_view(),
29 27 name='tag'),
30 28 # /boards/tag/tag_id/page/
31 29 url(r'^tag/(?P<tag_name>\w+)/page/(?P<page>\w+)/$',
32 30 tag_threads.TagView.as_view(), name='tag'),
33 31
34 32 # /boards/thread/
35 33 url(r'^thread/(?P<post_id>\w+)/$', views.thread.ThreadView.as_view(),
36 34 name='thread'),
37 35 url(r'^thread/(?P<post_id>\w+)/mode/(?P<mode>\w+)/$', views.thread.ThreadView
38 36 .as_view(), name='thread_mode'),
39 37
40 # /boards/post_admin/
41 url(r'^post_admin/(?P<post_id>\w+)/$', PostAdminView.as_view(),
42 name='post_admin'),
43
44 38 url(r'^settings/$', settings.SettingsView.as_view(), name='settings'),
45 39 url(r'^tags/$', all_tags.AllTagsView.as_view(), name='tags'),
46 40 url(r'^authors/$', AuthorsView.as_view(), name='authors'),
47 url(r'^delete/(?P<post_id>\w+)/$', DeletePostView.as_view(),
48 name='delete'),
49 41 url(r'^ban/(?P<post_id>\w+)/$', BanUserView.as_view(), name='ban'),
50 42
51 43 url(r'^banned/$', views.banned.BannedView.as_view(), name='banned'),
52 44 url(r'^staticpage/(?P<name>\w+)/$', StaticPageView.as_view(),
53 45 name='staticpage'),
54 46
55 47 # RSS feeds
56 48 url(r'^rss/$', AllThreadsFeed()),
57 49 url(r'^page/(?P<page>\w+)/rss/$', AllThreadsFeed()),
58 50 url(r'^tag/(?P<tag_name>\w+)/rss/$', TagThreadsFeed()),
59 51 url(r'^tag/(?P<tag_name>\w+)/page/(?P<page>\w+)/rss/$', TagThreadsFeed()),
60 52 url(r'^thread/(?P<post_id>\w+)/rss/$', ThreadPostsFeed()),
61 53
62 54 # i18n
63 55 url(r'^jsi18n/$', 'boards.views.cached_js_catalog', js_info_dict,
64 56 name='js_info_dict'),
65 57
66 58 # API
67 59 url(r'^api/post/(?P<post_id>\w+)/$', api.get_post, name="get_post"),
68 60 url(r'^api/diff_thread/(?P<thread_id>\w+)/(?P<last_update_time>\w+)/$',
69 61 api.api_get_threaddiff, name="get_thread_diff"),
70 62 url(r'^api/threads/(?P<count>\w+)/$', api.api_get_threads,
71 63 name='get_threads'),
72 64 url(r'^api/tags/$', api.api_get_tags, name='get_tags'),
73 65 url(r'^api/thread/(?P<opening_post_id>\w+)/$', api.api_get_thread_posts,
74 66 name='get_thread'),
75 67 url(r'^api/add_post/(?P<opening_post_id>\w+)/$', api.api_add_post,
76 68 name='add_post'),
77 69
78 70 # Search
79 71 url(r'^search/$', BoardSearchView.as_view(), name='search'),
80 72
81 73 # Post preview
82 74 url(r'^preview/$', PostPreviewView.as_view(), name='preview'),
83 75
84 76 url(r'^post_xml/(?P<post_id>\d+)$', get_post_sync_data,
85 77 name='post_sync_data'),
86 78
87 79 )
@@ -1,78 +1,43 b''
1 1 """
2 2 This module contains helper functions and helper classes.
3 3 """
4 import hashlib
5 4 import time
5 import hmac
6 6
7 7 from django.utils import timezone
8 8
9 9 from neboard import settings
10 10
11 11
12 12 KEY_CAPTCHA_FAILS = 'key_captcha_fails'
13 13 KEY_CAPTCHA_DELAY_TIME = 'key_captcha_delay_time'
14 14 KEY_CAPTCHA_LAST_ACTIVITY = 'key_captcha_last_activity'
15 15
16 16
17 def need_include_captcha(request):
18 """
19 Check if request is made by a user.
20 It contains rules which check for bots.
21 """
22
23 if not settings.ENABLE_CAPTCHA:
24 return False
25
26 enable_captcha = False
27
28 #newcomer
29 if KEY_CAPTCHA_LAST_ACTIVITY not in request.session:
30 return settings.ENABLE_CAPTCHA
31
32 last_activity = request.session[KEY_CAPTCHA_LAST_ACTIVITY]
33 current_delay = int(time.time()) - last_activity
34
35 delay_time = (request.session[KEY_CAPTCHA_DELAY_TIME]
36 if KEY_CAPTCHA_DELAY_TIME in request.session
37 else settings.CAPTCHA_DEFAULT_SAFE_TIME)
38
39 if current_delay < delay_time:
40 enable_captcha = True
41
42 return enable_captcha
43
44
45 def update_captcha_access(request, passed):
46 """
47 Update captcha fields.
48 It will reduce delay time if user passed captcha verification and
49 it will increase it otherwise.
50 """
51 session = request.session
52
53 delay_time = (request.session[KEY_CAPTCHA_DELAY_TIME]
54 if KEY_CAPTCHA_DELAY_TIME in request.session
55 else settings.CAPTCHA_DEFAULT_SAFE_TIME)
56
57 if passed:
58 delay_time -= 2 if delay_time >= 7 else 5
59 else:
60 delay_time += 10
61
62 session[KEY_CAPTCHA_LAST_ACTIVITY] = int(time.time())
63 session[KEY_CAPTCHA_DELAY_TIME] = delay_time
64
65
66 17 def get_client_ip(request):
67 18 x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
68 19 if x_forwarded_for:
69 20 ip = x_forwarded_for.split(',')[-1].strip()
70 21 else:
71 22 ip = request.META.get('REMOTE_ADDR')
72 23 return ip
73 24
74 25
75 26 def datetime_to_epoch(datetime):
76 27 return int(time.mktime(timezone.localtime(
77 28 datetime,timezone.get_current_timezone()).timetuple())
78 29 * 1000000 + datetime.microsecond)
30
31
32 def get_websocket_token(user_id='', timestamp=''):
33 """
34 Create token to validate information provided by new connection.
35 """
36
37 sign = hmac.new(settings.CENTRIFUGE_PROJECT_SECRET.encode())
38 sign.update(settings.CENTRIFUGE_PROJECT_ID.encode())
39 sign.update(user_id.encode())
40 sign.update(timestamp.encode())
41 token = sign.hexdigest()
42
43 return token No newline at end of file
@@ -1,13 +1,14 b''
1 1 from django.shortcuts import render
2 2
3 3 from boards.views.base import BaseBoardView
4 4 from boards.models.tag import Tag
5 5
6 6
7 7 class AllTagsView(BaseBoardView):
8 8
9 9 def get(self, request):
10 context = self.get_context_data(request=request)
11 context['all_tags'] = Tag.objects.get_not_empty_tags()
10 params = dict()
12 11
13 return render(request, 'boards/tags.html', context)
12 params['all_tags'] = Tag.objects.get_not_empty_tags()
13
14 return render(request, 'boards/tags.html', params)
@@ -1,139 +1,137 b''
1 import string
2
3 1 from django.db import transaction
4 2 from django.shortcuts import render, redirect
5 3
6 4 from boards import utils, settings
7 5 from boards.abstracts.paginator import get_paginator
8 6 from boards.abstracts.settingsmanager import get_settings_manager
9 7 from boards.forms import ThreadForm, PlainErrorList
10 8 from boards.models import Post, Thread, Ban, Tag
11 9 from boards.views.banned import BannedView
12 10 from boards.views.base import BaseBoardView, CONTEXT_FORM
13 11 from boards.views.posting_mixin import PostMixin
14 12
13
15 14 FORM_TAGS = 'tags'
16 15 FORM_TEXT = 'text'
17 16 FORM_TITLE = 'title'
18 17 FORM_IMAGE = 'image'
19 18
20 19 TAG_DELIMITER = ' '
21 20
22 21 PARAMETER_CURRENT_PAGE = 'current_page'
23 22 PARAMETER_PAGINATOR = 'paginator'
24 23 PARAMETER_THREADS = 'threads'
25 24
26 25 TEMPLATE = 'boards/posting_general.html'
27 26 DEFAULT_PAGE = 1
28 27
29 28
30 29 class AllThreadsView(PostMixin, BaseBoardView):
31 30
32 31 def __init__(self):
33 32 self.settings_manager = None
34 33 super(AllThreadsView, self).__init__()
35 34
36 35 def get(self, request, page=DEFAULT_PAGE, form=None):
37 context = self.get_context_data(request=request)
36 params = self.get_context_data(request=request)
38 37
39 38 if not form:
40 39 form = ThreadForm(error_class=PlainErrorList)
41 40
42 41 self.settings_manager = get_settings_manager(request)
43 42 paginator = get_paginator(self.get_threads(),
44 43 settings.THREADS_PER_PAGE)
45 44 paginator.current_page = int(page)
46 45
47 46 threads = paginator.page(page).object_list
48 47
49 context[PARAMETER_THREADS] = threads
50 context[CONTEXT_FORM] = form
48 params[PARAMETER_THREADS] = threads
49 params[CONTEXT_FORM] = form
51 50
52 self._get_page_context(paginator, context, page)
51 self._get_page_context(paginator, params, page)
53 52
54 return render(request, TEMPLATE, context)
53 return render(request, TEMPLATE, params)
55 54
56 55 def post(self, request, page=DEFAULT_PAGE):
57 56 form = ThreadForm(request.POST, request.FILES,
58 57 error_class=PlainErrorList)
59 58 form.session = request.session
60 59
61 60 if form.is_valid():
62 61 return self.create_thread(request, form)
63 62 if form.need_to_ban:
64 63 # Ban user because he is suspected to be a bot
65 64 self._ban_current_user(request)
66 65
67 66 return self.get(request, page, form)
68 67
69 @staticmethod
70 def _get_page_context(paginator, context, page):
68 def _get_page_context(self, paginator, params, page):
71 69 """
72 70 Get pagination context variables
73 71 """
74 72
75 context[PARAMETER_PAGINATOR] = paginator
76 context[PARAMETER_CURRENT_PAGE] = paginator.page(int(page))
73 params[PARAMETER_PAGINATOR] = paginator
74 params[PARAMETER_CURRENT_PAGE] = paginator.page(int(page))
77 75
78 76 @staticmethod
79 77 def parse_tags_string(tag_strings):
80 78 """
81 79 Parses tag list string and returns tag object list.
82 80 """
83 81
84 82 tags = []
85 83
86 84 if tag_strings:
87 85 tag_strings = tag_strings.split(TAG_DELIMITER)
88 86 for tag_name in tag_strings:
89 87 tag_name = tag_name.strip().lower()
90 88 if len(tag_name) > 0:
91 89 tag, created = Tag.objects.get_or_create(name=tag_name)
92 90 tags.append(tag)
93 91
94 92 return tags
95 93
96 94 @transaction.atomic
97 95 def create_thread(self, request, form, html_response=True):
98 96 """
99 97 Creates a new thread with an opening post.
100 98 """
101 99
102 100 ip = utils.get_client_ip(request)
103 101 is_banned = Ban.objects.filter(ip=ip).exists()
104 102
105 103 if is_banned:
106 104 if html_response:
107 105 return redirect(BannedView().as_view())
108 106 else:
109 107 return
110 108
111 109 data = form.cleaned_data
112 110
113 111 title = data[FORM_TITLE]
114 112 text = data[FORM_TEXT]
113 image = data.get(FORM_IMAGE)
115 114
116 115 text = self._remove_invalid_links(text)
117 116
118 if FORM_IMAGE in list(data.keys()):
119 image = data[FORM_IMAGE]
120 else:
121 image = None
122
123 117 tag_strings = data[FORM_TAGS]
124 118
125 119 tags = self.parse_tags_string(tag_strings)
126 120
127 121 post = Post.objects.create_post(title=title, text=text, image=image,
128 122 ip=ip, tags=tags)
129 123
124 # This is required to update the threads to which posts we have replied
125 # when creating this one
126 post.send_to_websocket(request)
127
130 128 if html_response:
131 129 return redirect(post.get_url())
132 130
133 131 def get_threads(self):
134 132 """
135 133 Gets list of threads that will be shown on a page.
136 134 """
137 135
138 136 return Thread.objects.all().order_by('-bump_time')\
139 137 .exclude(tags__in=self.settings_manager.get_hidden_tags())
@@ -1,248 +1,223 b''
1 1 from datetime import datetime
2 2 import json
3 3 import logging
4 4 from django.db import transaction
5 5 from django.http import HttpResponse
6 6 from django.shortcuts import get_object_or_404, render
7 7 from django.template import RequestContext
8 8 from django.utils import timezone
9 9 from django.core import serializers
10 from django.template.loader import render_to_string
11 10
12 11 from boards.forms import PostForm, PlainErrorList
13 12 from boards.models import Post, Thread, Tag
14 13 from boards.utils import datetime_to_epoch
15 14 from boards.views.thread import ThreadView
16 15
17 16 __author__ = 'neko259'
18 17
19 18 PARAMETER_TRUNCATED = 'truncated'
20 19 PARAMETER_TAG = 'tag'
21 20 PARAMETER_OFFSET = 'offset'
22 21 PARAMETER_DIFF_TYPE = 'type'
23 22
24 23 DIFF_TYPE_HTML = 'html'
25 24 DIFF_TYPE_JSON = 'json'
26 25
27 26 STATUS_OK = 'ok'
28 27 STATUS_ERROR = 'error'
29 28
30 29 logger = logging.getLogger(__name__)
31 30
32 31
33 32 @transaction.atomic
34 33 def api_get_threaddiff(request, thread_id, last_update_time):
35 34 """
36 35 Gets posts that were changed or added since time
37 36 """
38 37
39 38 thread = get_object_or_404(Post, id=thread_id).get_thread()
40 39
41 40 # Add 1 to ensure we don't load the same post over and over
42 41 last_update_timestamp = float(last_update_time) + 1
43 42
44 43 filter_time = datetime.fromtimestamp(last_update_timestamp / 1000000,
45 44 timezone.get_current_timezone())
46 45
47 46 json_data = {
48 47 'added': [],
49 48 'updated': [],
50 49 'last_update': None,
51 50 }
52 51 added_posts = Post.objects.filter(thread_new=thread,
53 52 pub_time__gt=filter_time) \
54 53 .order_by('pub_time')
55 54 updated_posts = Post.objects.filter(thread_new=thread,
56 55 pub_time__lte=filter_time,
57 56 last_edit_time__gt=filter_time)
58 57
59 diff_type = DIFF_TYPE_HTML
60 if PARAMETER_DIFF_TYPE in request.GET:
61 diff_type = request.GET[PARAMETER_DIFF_TYPE]
58 diff_type = request.GET.get(PARAMETER_DIFF_TYPE, DIFF_TYPE_HTML)
62 59
63 60 for post in added_posts:
64 json_data['added'].append(_get_post_data(post.id, diff_type, request))
61 json_data['added'].append(get_post_data(post.id, diff_type, request))
65 62 for post in updated_posts:
66 json_data['updated'].append(_get_post_data(post.id, diff_type, request))
63 json_data['updated'].append(get_post_data(post.id, diff_type, request))
67 64 json_data['last_update'] = datetime_to_epoch(thread.last_edit_time)
68 65
69 66 return HttpResponse(content=json.dumps(json_data))
70 67
71 68
72 69 def api_add_post(request, opening_post_id):
73 70 """
74 71 Adds a post and return the JSON response for it
75 72 """
76 73
77 74 opening_post = get_object_or_404(Post, id=opening_post_id)
78 75
79 76 logger.info('Adding post via api...')
80 77
81 78 status = STATUS_OK
82 79 errors = []
83 80
84 81 if request.method == 'POST':
85 82 form = PostForm(request.POST, request.FILES, error_class=PlainErrorList)
86 83 form.session = request.session
87 84
88 85 if form.need_to_ban:
89 86 # Ban user because he is suspected to be a bot
90 87 # _ban_current_user(request)
91 88 status = STATUS_ERROR
92 89 if form.is_valid():
93 90 post = ThreadView().new_post(request, form, opening_post,
94 91 html_response=False)
95 92 if not post:
96 93 status = STATUS_ERROR
97 94 else:
98 95 logger.info('Added post #%d via api.' % post.id)
99 96 else:
100 97 status = STATUS_ERROR
101 98 errors = form.as_json_errors()
102 99
103 100 response = {
104 101 'status': status,
105 102 'errors': errors,
106 103 }
107 104
108 105 return HttpResponse(content=json.dumps(response))
109 106
110 107
111 108 def get_post(request, post_id):
112 109 """
113 110 Gets the html of a post. Used for popups. Post can be truncated if used
114 111 in threads list with 'truncated' get parameter.
115 112 """
116 113
117 logger.info('Getting post #%s' % post_id)
118
119 114 post = get_object_or_404(Post, id=post_id)
120 115
121 116 context = RequestContext(request)
122 117 context['post'] = post
123 118 if PARAMETER_TRUNCATED in request.GET:
124 119 context[PARAMETER_TRUNCATED] = True
125 120
126 return render(request, 'boards/api_post.html', context)
121 # TODO Use dict here
122 return render(request, 'boards/api_post.html', context_instance=context)
127 123
128 124
129 125 # TODO Test this
130 126 def api_get_threads(request, count):
131 127 """
132 128 Gets the JSON thread opening posts list.
133 129 Parameters that can be used for filtering:
134 130 tag, offset (from which thread to get results)
135 131 """
136 132
137 133 if PARAMETER_TAG in request.GET:
138 134 tag_name = request.GET[PARAMETER_TAG]
139 135 if tag_name is not None:
140 136 tag = get_object_or_404(Tag, name=tag_name)
141 threads = tag.threads.filter(archived=False)
137 threads = tag.get_threads().filter(archived=False)
142 138 else:
143 139 threads = Thread.objects.filter(archived=False)
144 140
145 141 if PARAMETER_OFFSET in request.GET:
146 142 offset = request.GET[PARAMETER_OFFSET]
147 143 offset = int(offset) if offset is not None else 0
148 144 else:
149 145 offset = 0
150 146
151 147 threads = threads.order_by('-bump_time')
152 148 threads = threads[offset:offset + int(count)]
153 149
154 150 opening_posts = []
155 151 for thread in threads:
156 152 opening_post = thread.get_opening_post()
157 153
158 154 # TODO Add tags, replies and images count
159 opening_posts.append(_get_post_data(opening_post.id,
155 opening_posts.append(get_post_data(opening_post.id,
160 156 include_last_update=True))
161 157
162 158 return HttpResponse(content=json.dumps(opening_posts))
163 159
164 160
165 161 # TODO Test this
166 162 def api_get_tags(request):
167 163 """
168 164 Gets all tags or user tags.
169 165 """
170 166
171 167 # TODO Get favorite tags for the given user ID
172 168
173 169 tags = Tag.objects.get_not_empty_tags()
174 170 tag_names = []
175 171 for tag in tags:
176 172 tag_names.append(tag.name)
177 173
178 174 return HttpResponse(content=json.dumps(tag_names))
179 175
180 176
181 177 # TODO The result can be cached by the thread last update time
182 178 # TODO Test this
183 179 def api_get_thread_posts(request, opening_post_id):
184 180 """
185 181 Gets the JSON array of thread posts
186 182 """
187 183
188 184 opening_post = get_object_or_404(Post, id=opening_post_id)
189 185 thread = opening_post.get_thread()
190 186 posts = thread.get_replies()
191 187
192 188 json_data = {
193 189 'posts': [],
194 190 'last_update': None,
195 191 }
196 192 json_post_list = []
197 193
198 194 for post in posts:
199 json_post_list.append(_get_post_data(post.id))
195 json_post_list.append(get_post_data(post.id))
200 196 json_data['last_update'] = datetime_to_epoch(thread.last_edit_time)
201 197 json_data['posts'] = json_post_list
202 198
203 199 return HttpResponse(content=json.dumps(json_data))
204 200
205 201
206 202 def api_get_post(request, post_id):
207 203 """
208 204 Gets the JSON of a post. This can be
209 205 used as and API for external clients.
210 206 """
211 207
212 208 post = get_object_or_404(Post, id=post_id)
213 209
214 210 json = serializers.serialize("json", [post], fields=(
215 211 "pub_time", "_text_rendered", "title", "text", "image",
216 212 "image_width", "image_height", "replies", "tags"
217 213 ))
218 214
219 215 return HttpResponse(content=json)
220 216
221 217
222 # TODO Add pub time and replies
223 def _get_post_data(post_id, format_type=DIFF_TYPE_JSON, request=None,
224 include_last_update=False):
225 if format_type == DIFF_TYPE_HTML:
226 post = get_object_or_404(Post, id=post_id)
227
228 context = RequestContext(request)
229 context['post'] = post
230 if PARAMETER_TRUNCATED in request.GET:
231 context[PARAMETER_TRUNCATED] = True
232
233 return render_to_string('boards/api_post.html', context)
234 elif format_type == DIFF_TYPE_JSON:
235 post = get_object_or_404(Post, id=post_id)
236 post_json = {
237 'id': post.id,
238 'title': post.title,
239 'text': post.text.rendered,
240 }
241 if post.images.exists():
242 post_image = post.get_first_image()
243 post_json['image'] = post_image.image.url
244 post_json['image_preview'] = post_image.image.url_200x150
245 if include_last_update:
246 post_json['bump_time'] = datetime_to_epoch(
247 post.thread_new.bump_time)
248 return post_json
218 # TODO Remove this method and use post method directly
219 def get_post_data(post_id, format_type=DIFF_TYPE_JSON, request=None,
220 include_last_update=False):
221 post = get_object_or_404(Post, id=post_id)
222 return post.get_post_data(format_type=format_type, request=request,
223 include_last_update=include_last_update)
@@ -1,13 +1,13 b''
1 1 from django.shortcuts import render
2 2
3 3 from boards.authors import authors
4 4 from boards.views.base import BaseBoardView
5 5
6 6
7 7 class AuthorsView(BaseBoardView):
8 8
9 9 def get(self, request):
10 context = self.get_context_data(request=request)
11 context['authors'] = authors
10 params = dict()
11 params['authors'] = authors
12 12
13 return render(request, 'boards/authors.html', context)
13 return render(request, 'boards/authors.html', params)
@@ -1,16 +1,17 b''
1 1 from django.shortcuts import get_object_or_404, render
2 2 from boards import utils
3 3 from boards.models import Ban
4 4 from boards.views.base import BaseBoardView
5 5
6 6
7 7 class BannedView(BaseBoardView):
8 8
9 9 def get(self, request):
10 10 """Show the page that notifies that user is banned"""
11 11
12 context = self.get_context_data(request=request)
12 params = dict()
13 13
14 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)
15 params['ban_reason'] = ban.reason
16
17 return render(request, 'boards/staticpages/banned.html', params)
@@ -1,35 +1,31 b''
1 1 from django.db import transaction
2 2 from django.template import RequestContext
3 3 from django.views.generic import View
4 4
5 5 from boards import utils
6 6 from boards.models.user import Ban
7 7
8 8
9 9 BAN_REASON_SPAM = 'Autoban: spam bot'
10 10
11 11 CONTEXT_FORM = 'form'
12 12
13 13
14 14 class BaseBoardView(View):
15 15
16 16 def get_context_data(self, **kwargs):
17 request = kwargs['request']
18 # context = self._default_context(request)
19 context = RequestContext(request)
20
21 return context
17 return dict()
22 18
23 19 @transaction.atomic
24 20 def _ban_current_user(self, request):
25 21 """
26 22 Add current user to the IP ban list
27 23 """
28 24
29 25 ip = utils.get_client_ip(request)
30 26 ban, created = Ban.objects.get_or_create(ip=ip)
31 27 if created:
32 28 ban.can_read = False
33 29 ban.reason = BAN_REASON_SPAM
34 30 ban.save()
35 31
@@ -1,39 +1,40 b''
1 PARAM_NEXT = 'next'
1 2 PARAMETER_METHOD = 'method'
2 3
3 4 from django.shortcuts import redirect
4 5 from django.http import HttpResponseRedirect
5 6
6 7
7 8 class RedirectNextMixin:
8 9
9 10 def redirect_to_next(self, request):
10 11 """
11 12 If a 'next' parameter was specified, redirect to the next page. This
12 13 is used when the user is required to return to some page after the
13 14 current view has finished its work.
14 15 """
15 16
16 if 'next' in request.GET:
17 next_page = request.GET['next']
17 if PARAM_NEXT in request.GET:
18 next_page = request.GET[PARAM_NEXT]
18 19 return HttpResponseRedirect(next_page)
19 20 else:
20 21 return redirect('index')
21 22
22 23
23 24 class DispatcherMixin:
24 25 """
25 26 This class contains a dispather method that can run a method specified by
26 27 'method' request parameter.
27 28 """
28 29
29 30 def dispatch_method(self, *args, **kwargs):
30 31 request = args[0]
31 32
32 33 method_name = None
33 34 if PARAMETER_METHOD in request.GET:
34 35 method_name = request.GET[PARAMETER_METHOD]
35 36 elif PARAMETER_METHOD in request.POST:
36 37 method_name = request.POST[PARAMETER_METHOD]
37 38
38 39 if method_name:
39 40 return getattr(self, method_name)(*args, **kwargs)
@@ -1,13 +1,17 b''
1 1 from django.shortcuts import render
2 2
3 3 from boards.views.base import BaseBoardView
4 4
5 5
6 6 class NotFoundView(BaseBoardView):
7 7 """
8 8 Page 404 (not found)
9 9 """
10 10
11 11 def get(self, request):
12 context = self.get_context_data(request=request)
13 return render(request, 'boards/404.html', context)
12 params = self.get_context_data()
13
14 response = render(request, 'boards/404.html', params)
15 response.status_code = 404
16
17 return response
@@ -1,35 +1,37 b''
1 1 from django.shortcuts import render
2 2 from django.template import RequestContext
3 3 from django.views.generic import View
4 4
5 5 from boards.mdx_neboard import bbcode_extended
6 6
7 7 FORM_QUERY = 'query'
8 8
9 9 CONTEXT_RESULT = 'result'
10 10 CONTEXT_QUERY = 'query'
11 11
12 12 __author__ = 'neko259'
13 13
14 14 TEMPLATE = 'boards/preview.html'
15 15
16 16
17 17 class PostPreviewView(View):
18 18 def get(self, request):
19 19 context = RequestContext(request)
20 20
21 return render(request, TEMPLATE, context)
21 # TODO Use dict here
22 return render(request, TEMPLATE, context_instance=context)
22 23
23 24 def post(self, request):
24 25 context = RequestContext(request)
25 26
26 27 if FORM_QUERY in request.POST:
27 28 raw_text = request.POST[FORM_QUERY]
28 29
29 30 if len(raw_text) >= 0:
30 31 rendered_text = bbcode_extended(raw_text)
31 32
32 33 context[CONTEXT_RESULT] = rendered_text
33 34 context[CONTEXT_QUERY] = raw_text
34 35
35 return render(request, TEMPLATE, context)
36 # TODO Use dict here
37 return render(request, TEMPLATE, context_instance=context)
@@ -1,40 +1,43 b''
1 1 from django.shortcuts import render
2 from django.template import RequestContext
3 2 from django.views.generic import View
4 3 from haystack.query import SearchQuerySet
4
5 5 from boards.abstracts.paginator import get_paginator
6 6 from boards.forms import SearchForm, PlainErrorList
7 7
8
9 MIN_QUERY_LENGTH = 3
10 RESULTS_PER_PAGE = 10
11
8 12 FORM_QUERY = 'query'
9 13
10 14 CONTEXT_QUERY = 'query'
11 15 CONTEXT_FORM = 'form'
12 16 CONTEXT_PAGE = 'page'
13 17
14 18 REQUEST_PAGE = 'page'
15 19
16 20 __author__ = 'neko259'
17 21
18 22 TEMPLATE = 'search/search.html'
19 23
20 24
21 25 class BoardSearchView(View):
22 26 def get(self, request):
23 context = RequestContext(request)
27 params = dict()
28
24 29 form = SearchForm(request.GET, error_class=PlainErrorList)
25 context[CONTEXT_FORM] = form
30 params[CONTEXT_FORM] = form
26 31
27 32 if form.is_valid():
28 33 query = form.cleaned_data[FORM_QUERY]
29 if len(query) >= 3:
30 results = SearchQuerySet().auto_query(query).order_by('-id').load_all()
31 paginator = get_paginator(results, 10)
34 if len(query) >= MIN_QUERY_LENGTH:
35 results = SearchQuerySet().auto_query(query).order_by('-id')
36 paginator = get_paginator(results, RESULTS_PER_PAGE)
32 37
33 if REQUEST_PAGE in request.GET:
34 page = int(request.GET[REQUEST_PAGE])
35 else:
36 page = 1
37 context[CONTEXT_PAGE] = paginator.page(page)
38 context[CONTEXT_QUERY] = query
38 page = int(request.GET.get(REQUEST_PAGE, '1'))
39 39
40 return render(request, TEMPLATE, context)
40 params[CONTEXT_PAGE] = paginator.page(page)
41 params[CONTEXT_QUERY] = query
42
43 return render(request, TEMPLATE, params)
@@ -1,38 +1,41 b''
1 1 from django.db import transaction
2 2 from django.shortcuts import render, redirect
3 3
4 4 from boards.abstracts.settingsmanager import get_settings_manager
5 5 from boards.views.base import BaseBoardView, CONTEXT_FORM
6 6 from boards.forms import SettingsForm, PlainErrorList
7 7
8 FORM_THEME = 'theme'
9
8 10 CONTEXT_HIDDEN_TAGS = 'hidden_tags'
9 11
10 12
11 13 class SettingsView(BaseBoardView):
12 14
13 15 def get(self, request):
14 context = self.get_context_data(request=request)
16 params = self.get_context_data()
15 17 settings_manager = get_settings_manager(request)
16 18
17 19 selected_theme = settings_manager.get_theme()
18 20
19 form = SettingsForm(initial={'theme': selected_theme},
21 form = SettingsForm(initial={FORM_THEME: selected_theme},
20 22 error_class=PlainErrorList)
21 23
22 context[CONTEXT_FORM] = form
23 context[CONTEXT_HIDDEN_TAGS] = settings_manager.get_hidden_tags()
24 params[CONTEXT_FORM] = form
25 params[CONTEXT_HIDDEN_TAGS] = settings_manager.get_hidden_tags()
24 26
25 return render(request, 'boards/settings.html', context)
27 # TODO Use dict here
28 return render(request, 'boards/settings.html', params)
26 29
27 30 def post(self, request):
28 31 settings_manager = get_settings_manager(request)
29 32
30 33 with transaction.atomic():
31 34 form = SettingsForm(request.POST, error_class=PlainErrorList)
32 35
33 36 if form.is_valid():
34 selected_theme = form.cleaned_data['theme']
37 selected_theme = form.cleaned_data[FORM_THEME]
35 38
36 39 settings_manager.set_theme(selected_theme)
37 40
38 41 return redirect('settings')
@@ -1,14 +1,13 b''
1 1 from django.shortcuts import render
2 2
3 3 from boards.views.base import BaseBoardView
4 4
5 5
6 6 class StaticPageView(BaseBoardView):
7 7
8 8 def get(self, request, name):
9 9 """
10 10 Show a static page that needs only tags list and a CSS
11 11 """
12 12
13 context = self.get_context_data(request=request)
14 return render(request, 'boards/staticpages/' + name + '.html', context)
13 return render(request, 'boards/staticpages/' + name + '.html')
@@ -1,92 +1,95 b''
1 1 from django.shortcuts import get_object_or_404
2 2
3 3 from boards.abstracts.settingsmanager import get_settings_manager
4 from boards.models import Tag
4 from boards.models import Tag, Thread
5 5 from boards.views.all_threads import AllThreadsView, DEFAULT_PAGE
6 6 from boards.views.mixins import DispatcherMixin, RedirectNextMixin
7 7 from boards.forms import ThreadForm, PlainErrorList
8 8
9 PARAM_HIDDEN_TAGS = 'hidden_tags'
10 PARAM_FAV_TAGS = 'fav_tags'
11 PARAM_TAG = 'tag'
9 12
10 13 __author__ = 'neko259'
11 14
12 15
13 16 class TagView(AllThreadsView, DispatcherMixin, RedirectNextMixin):
14 17
15 18 tag_name = None
16 19
17 20 def get_threads(self):
18 21 tag = get_object_or_404(Tag, name=self.tag_name)
19 22
20 return tag.threads.all().order_by('-bump_time')
23 return tag.get_threads()
21 24
22 25 def get_context_data(self, **kwargs):
23 context = super(TagView, self).get_context_data(**kwargs)
26 params = super(TagView, self).get_context_data(**kwargs)
24 27
25 28 settings_manager = get_settings_manager(kwargs['request'])
26 29
27 30 tag = get_object_or_404(Tag, name=self.tag_name)
28 context['tag'] = tag
31 params[PARAM_TAG] = tag
29 32
30 context['fav_tags'] = settings_manager.get_fav_tags()
31 context['hidden_tags'] = settings_manager.get_hidden_tags()
33 params[PARAM_FAV_TAGS] = settings_manager.get_fav_tags()
34 params[PARAM_HIDDEN_TAGS] = settings_manager.get_hidden_tags()
32 35
33 return context
36 return params
34 37
35 38 def get(self, request, tag_name, page=DEFAULT_PAGE, form=None):
36 39 self.tag_name = tag_name
37 40
38 41 dispatch_result = self.dispatch_method(request)
39 42 if dispatch_result:
40 43 return dispatch_result
41 44 else:
42 45 return super(TagView, self).get(request, page, form)
43 46
44 47 def post(self, request, tag_name, page=DEFAULT_PAGE):
45 48 form = ThreadForm(request.POST, request.FILES,
46 49 error_class=PlainErrorList)
47 50 form.session = request.session
48 51
49 52 if form.is_valid():
50 53 return self.create_thread(request, form)
51 54 if form.need_to_ban:
52 55 # Ban user because he is suspected to be a bot
53 56 self._ban_current_user(request)
54 57
55 58 return self.get(request, tag_name, page, form)
56 59
57 60 def subscribe(self, request):
58 61 tag = get_object_or_404(Tag, name=self.tag_name)
59 62
60 63 settings_manager = get_settings_manager(request)
61 64 settings_manager.add_fav_tag(tag)
62 65
63 66 return self.redirect_to_next(request)
64 67
65 68 def unsubscribe(self, request):
66 69 tag = get_object_or_404(Tag, name=self.tag_name)
67 70
68 71 settings_manager = get_settings_manager(request)
69 72 settings_manager.del_fav_tag(tag)
70 73
71 74 return self.redirect_to_next(request)
72 75
73 76 def hide(self, request):
74 77 """
75 78 Adds tag to user's hidden tags. Threads with this tag will not be
76 79 shown.
77 80 """
78 81
79 82 tag = get_object_or_404(Tag, name=self.tag_name)
80 83
81 84 settings_manager = get_settings_manager(request)
82 85 settings_manager.add_hidden_tag(tag)
83 86
84 87 def unhide(self, request):
85 88 """
86 89 Removed tag from user's hidden tags.
87 90 """
88 91
89 92 tag = get_object_or_404(Tag, name=self.tag_name)
90 93
91 94 settings_manager = get_settings_manager(request)
92 95 settings_manager.del_hidden_tag(tag)
@@ -1,142 +1,142 b''
1 1 from django.core.urlresolvers import reverse
2 2 from django.db import transaction
3 3 from django.http import Http404
4 4 from django.shortcuts import get_object_or_404, render, redirect
5 5 from django.views.generic.edit import FormMixin
6 6
7 7 from boards import utils, settings
8 8 from boards.forms import PostForm, PlainErrorList
9 9 from boards.models import Post, Ban
10 10 from boards.views.banned import BannedView
11 11 from boards.views.base import BaseBoardView, CONTEXT_FORM
12 12 from boards.views.posting_mixin import PostMixin
13 import neboard
13 14
14 15 TEMPLATE_GALLERY = 'boards/thread_gallery.html'
15 16 TEMPLATE_NORMAL = 'boards/thread.html'
16 17
17 18 CONTEXT_POSTS = 'posts'
18 19 CONTEXT_OP = 'opening_post'
19 20 CONTEXT_BUMPLIMIT_PRG = 'bumplimit_progress'
20 21 CONTEXT_POSTS_LEFT = 'posts_left'
21 22 CONTEXT_LASTUPDATE = "last_update"
22 23 CONTEXT_MAX_REPLIES = 'max_replies'
23 24 CONTEXT_THREAD = 'thread'
24 25 CONTEXT_BUMPABLE = 'bumpable'
26 CONTEXT_WS_TOKEN = 'ws_token'
27 CONTEXT_WS_PROJECT = 'ws_project'
28 CONTEXT_WS_HOST = 'ws_host'
29 CONTEXT_WS_PORT = 'ws_port'
25 30
26 31 FORM_TITLE = 'title'
27 32 FORM_TEXT = 'text'
28 33 FORM_IMAGE = 'image'
29 34
30 35 MODE_GALLERY = 'gallery'
31 36 MODE_NORMAL = 'normal'
32 37
33 38
34 39 class ThreadView(BaseBoardView, PostMixin, FormMixin):
35 40
36 41 def get(self, request, post_id, mode=MODE_NORMAL, form=None):
37 42 try:
38 43 opening_post = Post.objects.filter(id=post_id).only('thread_new')[0]
39 44 except IndexError:
40 45 raise Http404
41 46
42 47 # If this is not OP, don't show it as it is
43 48 if not opening_post or not opening_post.is_opening():
44 49 raise Http404
45 50
46 51 if not form:
47 52 form = PostForm(error_class=PlainErrorList)
48 53
49 54 thread_to_show = opening_post.get_thread()
50 55
51 context = self.get_context_data(request=request)
56 params = dict()
57
58 params[CONTEXT_FORM] = form
59 params[CONTEXT_LASTUPDATE] = str(utils.datetime_to_epoch(
60 thread_to_show.last_edit_time))
61 params[CONTEXT_THREAD] = thread_to_show
62 params[CONTEXT_MAX_REPLIES] = settings.MAX_POSTS_PER_THREAD
52 63
53 context[CONTEXT_FORM] = form
54 context[CONTEXT_LASTUPDATE] = utils.datetime_to_epoch(
55 thread_to_show.last_edit_time)
56 context[CONTEXT_THREAD] = thread_to_show
57 context[CONTEXT_MAX_REPLIES] = settings.MAX_POSTS_PER_THREAD
64 if settings.WEBSOCKETS_ENABLED:
65 params[CONTEXT_WS_TOKEN] = utils.get_websocket_token(
66 timestamp=params[CONTEXT_LASTUPDATE])
67 params[CONTEXT_WS_PROJECT] = neboard.settings.CENTRIFUGE_PROJECT_ID
68 params[CONTEXT_WS_HOST] = request.get_host().split(':')[0]
69 params[CONTEXT_WS_PORT] = neboard.settings.CENTRIFUGE_PORT
58 70
71 # TODO Move this to subclasses: NormalThreadView, GalleryThreadView etc
59 72 if MODE_NORMAL == mode:
60 73 bumpable = thread_to_show.can_bump()
61 context[CONTEXT_BUMPABLE] = bumpable
74 params[CONTEXT_BUMPABLE] = bumpable
62 75 if bumpable:
63 76 left_posts = settings.MAX_POSTS_PER_THREAD \
64 77 - thread_to_show.get_reply_count()
65 context[CONTEXT_POSTS_LEFT] = left_posts
66 context[CONTEXT_BUMPLIMIT_PRG] = str(
78 params[CONTEXT_POSTS_LEFT] = left_posts
79 params[CONTEXT_BUMPLIMIT_PRG] = str(
67 80 float(left_posts) / settings.MAX_POSTS_PER_THREAD * 100)
68 81
69 context[CONTEXT_OP] = opening_post
82 params[CONTEXT_OP] = opening_post
70 83
71 84 document = TEMPLATE_NORMAL
72 85 elif MODE_GALLERY == mode:
73 context[CONTEXT_POSTS] = thread_to_show.get_replies_with_images(
86 params[CONTEXT_POSTS] = thread_to_show.get_replies_with_images(
74 87 view_fields_only=True)
75 88
76 89 document = TEMPLATE_GALLERY
77 90 else:
78 91 raise Http404
79 92
80 return render(request, document, context)
93 return render(request, document, params)
81 94
82 95 def post(self, request, post_id, mode=MODE_NORMAL):
83 96 opening_post = get_object_or_404(Post, id=post_id)
84 97
85 98 # If this is not OP, don't show it as it is
86 99 if not opening_post.is_opening():
87 100 raise Http404
88 101
89 102 if not opening_post.get_thread().archived:
90 103 form = PostForm(request.POST, request.FILES,
91 104 error_class=PlainErrorList)
92 105 form.session = request.session
93 106
94 107 if form.is_valid():
95 108 return self.new_post(request, form, opening_post)
96 109 if form.need_to_ban:
97 110 # Ban user because he is suspected to be a bot
98 111 self._ban_current_user(request)
99 112
100 113 return self.get(request, post_id, mode, form)
101 114
102 @transaction.atomic
103 115 def new_post(self, request, form, opening_post=None, html_response=True):
104 116 """Add a new post (in thread or as a reply)."""
105 117
106 118 ip = utils.get_client_ip(request)
107 is_banned = Ban.objects.filter(ip=ip).exists()
108
109 if is_banned:
110 if html_response:
111 return redirect(BannedView().as_view())
112 else:
113 return None
114 119
115 120 data = form.cleaned_data
116 121
117 122 title = data[FORM_TITLE]
118 123 text = data[FORM_TEXT]
124 image = data.get(FORM_IMAGE)
119 125
120 126 text = self._remove_invalid_links(text)
121 127
122 if FORM_IMAGE in list(data.keys()):
123 image = data[FORM_IMAGE]
124 else:
125 image = None
126
127 tags = []
128
129 128 post_thread = opening_post.get_thread()
130 129
131 130 post = Post.objects.create_post(title=title, text=text, image=image,
132 thread=post_thread, ip=ip, tags=tags)
131 thread=post_thread, ip=ip)
132 post.send_to_websocket(request)
133 133
134 134 thread_to_show = (opening_post.id if opening_post else post.id)
135 135
136 136 if html_response:
137 137 if opening_post:
138 138 return redirect(
139 139 reverse('thread', kwargs={'post_id': thread_to_show})
140 140 + '#' + str(post.id))
141 141 else:
142 142 return post
@@ -1,10 +1,10 b''
1 #!/usr/bin/env python
1 #!/usr/bin/env python3
2 2 import os
3 3 import sys
4 4
5 5 if __name__ == "__main__":
6 6 os.environ.setdefault("DJANGO_SETTINGS_MODULE", "neboard.settings")
7 7
8 8 from django.core.management import execute_from_command_line
9 9
10 10 execute_from_command_line(sys.argv)
@@ -1,235 +1,233 b''
1 1 # Django settings for neboard project.
2 2 import os
3 3 from boards.mdx_neboard import bbcode_extended
4 4
5 5 DEBUG = True
6 6 TEMPLATE_DEBUG = DEBUG
7 7
8 8 ADMINS = (
9 9 # ('Your Name', 'your_email@example.com'),
10 10 ('admin', 'admin@example.com')
11 11 )
12 12
13 13 MANAGERS = ADMINS
14 14
15 15 DATABASES = {
16 16 'default': {
17 17 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'.
18 18 'NAME': 'database.db', # Or path to database file if using sqlite3.
19 19 'USER': '', # Not used with sqlite3.
20 20 'PASSWORD': '', # Not used with sqlite3.
21 21 'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
22 22 'PORT': '', # Set to empty string for default. Not used with sqlite3.
23 23 'CONN_MAX_AGE': None,
24 24 }
25 25 }
26 26
27 27 # Local time zone for this installation. Choices can be found here:
28 28 # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
29 29 # although not all choices may be available on all operating systems.
30 30 # In a Windows environment this must be set to your system time zone.
31 31 TIME_ZONE = 'Europe/Kiev'
32 32
33 33 # Language code for this installation. All choices can be found here:
34 34 # http://www.i18nguy.com/unicode/language-identifiers.html
35 35 LANGUAGE_CODE = 'en'
36 36
37 37 SITE_ID = 1
38 38
39 39 # If you set this to False, Django will make some optimizations so as not
40 40 # to load the internationalization machinery.
41 41 USE_I18N = True
42 42
43 43 # If you set this to False, Django will not format dates, numbers and
44 44 # calendars according to the current locale.
45 45 USE_L10N = True
46 46
47 47 # If you set this to False, Django will not use timezone-aware datetimes.
48 48 USE_TZ = True
49 49
50 50 # Absolute filesystem path to the directory that will hold user-uploaded files.
51 51 # Example: "/home/media/media.lawrence.com/media/"
52 52 MEDIA_ROOT = './media/'
53 53
54 54 # URL that handles the media served from MEDIA_ROOT. Make sure to use a
55 55 # trailing slash.
56 56 # Examples: "http://media.lawrence.com/media/", "http://example.com/media/"
57 57 MEDIA_URL = '/media/'
58 58
59 59 # Absolute path to the directory static files should be collected to.
60 60 # Don't put anything in this directory yourself; store your static files
61 61 # in apps' "static/" subdirectories and in STATICFILES_DIRS.
62 62 # Example: "/home/media/media.lawrence.com/static/"
63 63 STATIC_ROOT = ''
64 64
65 65 # URL prefix for static files.
66 66 # Example: "http://media.lawrence.com/static/"
67 67 STATIC_URL = '/static/'
68 68
69 69 # Additional locations of static files
70 70 # It is really a hack, put real paths, not related
71 71 STATICFILES_DIRS = (
72 72 os.path.dirname(__file__) + '/boards/static',
73 73
74 74 # '/d/work/python/django/neboard/neboard/boards/static',
75 75 # Put strings here, like "/home/html/static" or "C:/www/django/static".
76 76 # Always use forward slashes, even on Windows.
77 77 # Don't forget to use absolute paths, not relative paths.
78 78 )
79 79
80 80 # List of finder classes that know how to find static files in
81 81 # various locations.
82 82 STATICFILES_FINDERS = (
83 83 'django.contrib.staticfiles.finders.FileSystemFinder',
84 84 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
85 'compressor.finders.CompressorFinder',
85 86 )
86 87
87 88 if DEBUG:
88 89 STATICFILES_STORAGE = \
89 90 'django.contrib.staticfiles.storage.StaticFilesStorage'
90 91 else:
91 92 STATICFILES_STORAGE = \
92 93 'django.contrib.staticfiles.storage.CachedStaticFilesStorage'
93 94
94 95 # Make this unique, and don't share it with anybody.
95 96 SECRET_KEY = '@1rc$o(7=tt#kd+4s$u6wchm**z^)4x90)7f6z(i&amp;55@o11*8o'
96 97
97 98 # List of callables that know how to import templates from various sources.
98 99 TEMPLATE_LOADERS = (
99 100 'django.template.loaders.filesystem.Loader',
100 101 'django.template.loaders.app_directories.Loader',
101 102 )
102 103
103 104 TEMPLATE_CONTEXT_PROCESSORS = (
104 105 'django.core.context_processors.media',
105 106 'django.core.context_processors.static',
106 107 'django.core.context_processors.request',
107 108 'django.contrib.auth.context_processors.auth',
108 109 'boards.context_processors.user_and_ui_processor',
109 110 )
110 111
111 112 MIDDLEWARE_CLASSES = (
112 113 'django.contrib.sessions.middleware.SessionMiddleware',
113 114 'django.middleware.locale.LocaleMiddleware',
114 115 'django.middleware.common.CommonMiddleware',
115 116 'django.contrib.auth.middleware.AuthenticationMiddleware',
116 117 'django.contrib.messages.middleware.MessageMiddleware',
117 118 'boards.middlewares.BanMiddleware',
118 'boards.middlewares.MinifyHTMLMiddleware',
119 119 )
120 120
121 121 ROOT_URLCONF = 'neboard.urls'
122 122
123 123 # Python dotted path to the WSGI application used by Django's runserver.
124 124 WSGI_APPLICATION = 'neboard.wsgi.application'
125 125
126 126 TEMPLATE_DIRS = (
127 127 # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
128 128 # Always use forward slashes, even on Windows.
129 129 # Don't forget to use absolute paths, not relative paths.
130 130 'templates',
131 131 )
132 132
133 133 INSTALLED_APPS = (
134 134 'django.contrib.auth',
135 135 'django.contrib.contenttypes',
136 136 'django.contrib.sessions',
137 137 # 'django.contrib.sites',
138 138 'django.contrib.messages',
139 139 'django.contrib.staticfiles',
140 140 # Uncomment the next line to enable the admin:
141 141 'django.contrib.admin',
142 142 # Uncomment the next line to enable admin documentation:
143 143 # 'django.contrib.admindocs',
144 144 'django.contrib.humanize',
145 145 'django_cleanup',
146 146
147 # Migrations
148 'south',
149 147 'debug_toolbar',
150 148
151 149 # Search
152 150 'haystack',
153 151
154 152 'boards',
155 153 )
156 154
157 155 # A sample logging configuration. The only tangible logging
158 156 # performed by this configuration is to send an email to
159 157 # the site admins on every HTTP 500 error when DEBUG=False.
160 158 # See http://docs.djangoproject.com/en/dev/topics/logging for
161 159 # more details on how to customize your logging configuration.
162 160 LOGGING = {
163 161 'version': 1,
164 162 'disable_existing_loggers': False,
165 163 'formatters': {
166 164 'verbose': {
167 'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s'
165 'format': '%(levelname)s %(asctime)s %(name)s %(process)d %(thread)d %(message)s'
168 166 },
169 167 'simple': {
170 'format': '%(levelname)s %(asctime)s [%(module)s] %(message)s'
168 'format': '%(levelname)s %(asctime)s [%(name)s] %(message)s'
171 169 },
172 170 },
173 171 'filters': {
174 172 'require_debug_false': {
175 173 '()': 'django.utils.log.RequireDebugFalse'
176 174 }
177 175 },
178 176 'handlers': {
179 177 'console': {
180 178 'level': 'DEBUG',
181 179 'class': 'logging.StreamHandler',
182 180 'formatter': 'simple'
183 181 },
184 182 },
185 183 'loggers': {
186 184 'boards': {
187 185 'handlers': ['console'],
188 186 'level': 'DEBUG',
189 187 }
190 188 },
191 189 }
192 190
193 191 HAYSTACK_CONNECTIONS = {
194 192 'default': {
195 193 'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine',
196 194 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'),
197 195 },
198 196 }
199 197
200 MARKUP_FIELD_TYPES = (
201 ('bbcode', bbcode_extended),
202 )
203
204 198 THEMES = [
205 199 ('md', 'Mystic Dark'),
206 200 ('md_centered', 'Mystic Dark (centered)'),
207 201 ('sw', 'Snow White'),
208 202 ('pg', 'Photon Gray'),
209 203 ]
210 204
211 POPULAR_TAGS = 10
212
213 205 POSTING_DELAY = 20 # seconds
214 206
215 COMPRESS_HTML = True
207 # Websocket settins
208 CENTRIFUGE_HOST = 'localhost'
209 CENTRIFUGE_PORT = '9090'
210
211 CENTRIFUGE_ADDRESS = 'http://{}:{}'.format(CENTRIFUGE_HOST, CENTRIFUGE_PORT)
212 CENTRIFUGE_PROJECT_ID = '<project id here>'
213 CENTRIFUGE_PROJECT_SECRET = '<project secret here>'
214 CENTRIFUGE_TIMEOUT = 5
216 215
217 216 # Debug mode middlewares
218 217 if DEBUG:
219 218 MIDDLEWARE_CLASSES += (
220 219 'debug_toolbar.middleware.DebugToolbarMiddleware',
221 220 )
222 221
223 222 def custom_show_toolbar(request):
224 return False
223 return True
225 224
226 225 DEBUG_TOOLBAR_CONFIG = {
227 226 'ENABLE_STACKTRACES': True,
228 227 'SHOW_TOOLBAR_CALLBACK': 'neboard.settings.custom_show_toolbar',
229 228 }
230 229
231 230 # FIXME Uncommenting this fails somehow. Need to investigate this
232 231 #DEBUG_TOOLBAR_PANELS += (
233 232 # 'debug_toolbar.panels.profiling.ProfilingDebugPanel',
234 233 #)
235
@@ -1,55 +1,38 b''
1 1 # INTRO #
2 2
3 3 This project aims to create centralized forum-like discussion platform with
4 4 anonymity in mind.
5 5
6 6 Main repository: https://bitbucket.org/neko259/neboard/
7 7
8 8 Site: http://neboard.me/
9 9
10 # DEPENDENCIES #
11
12 ## REQUIRED ##
13
14 * pillow
15 * django >= 1.6
16 * django_cleanup
17 * django-markupfield
18 * markdown
19 * python-markdown
20 * django-simple-captcha
21 * line-profiler
22
23 ## OPTIONAL ##
24
25 * django-debug-toolbar
26
27 10 # INSTALLATION #
28 11
29 12 1. Install all dependencies over pip or system-wide
30 13 2. Setup a database in `neboard/settings.py`
31 14 3. Run `./manage.py syncdb` and ensure the database was created
32 15 4. Run `./manage.py migrate boards` to apply all south migrations
33 16
34 17 # RUNNING #
35 18
36 19 You can run the server using django default embedded webserver by running
37 20
38 21 ./manage.py runserver <address>:<port>
39 22
40 23 See django-admin command help for details
41 24
42 25 Also consider using wsgi or fcgi interfaces on production servers.
43 26
44 27 # UPGRADE #
45 28
46 29 1. Backup your project data.
47 30 2. Save the settings in `neboard/settings.py` and `boards/settings.py`
48 31 3. Copy the project contents over the old project directory
49 32 4. Run migrations by `./manage.py migrate boards`
50 33
51 34 You can also just clone the mercurial project and pull it to update
52 35
53 36 # CONCLUSION #
54 37
55 38 Enjoy our software and thank you!
@@ -1,10 +1,9 b''
1 1 httplib2
2 2 simplejson
3 south>=0.8.4
3 adjacent
4 4 haystack
5 5 pillow
6 django>=1.6
6 django>=1.7
7 7 django_cleanup
8 django-markupfield
9 8 bbcode
10 9 ecdsa
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
General Comments 0
You need to be logged in to leave comments. Login now