diff --git a/biostar/forum/ajax.py b/biostar/forum/ajax.py
index 18d8afebe..bccc76abe 100644
--- a/biostar/forum/ajax.py
+++ b/biostar/forum/ajax.py
@@ -3,6 +3,7 @@
import json
from ratelimit.decorators import ratelimit
from urllib import request as builtin_request
+from difflib import Differ
# import requests
from urllib.parse import urlencode
from datetime import datetime, timedelta
@@ -20,7 +21,7 @@
from biostar.accounts.models import Profile, User
from . import auth, util, forms, tasks, search, views, const, moderate
-from .models import Post, Vote, Subscription, delete_post_cache, SharedLink
+from .models import Post, Vote, Subscription, delete_post_cache, SharedLink, Diff
@@ -237,21 +238,6 @@ def get_fields(request, post=None):
return fields
-def set_post(post, user, fields):
- # Set the fields for this post.
- if post.is_toplevel:
- post.title = fields.get('title', post.title)
- post.type = fields.get('post_type', post.type)
- post.tag_val = fields.get('tag_val', post.tag_val)
-
- post.lastedit_user = user
- post.lastedit_date = util.now()
- post.content = fields.get('content', post.content)
- post.save()
-
- return post
-
-
@ajax_limited(key=RATELIMIT_KEY, rate=EDIT_RATE)
@ajax_error_wrapper(method="POST", login_required=True)
def ajax_edit(request, uid):
@@ -279,7 +265,7 @@ def ajax_edit(request, uid):
form = forms.PostShortForm(post=post, user=user, data=fields)
if form.is_valid():
- post = set_post(post, user, form.cleaned_data)
+ form.edit()
return ajax_success(msg='Edited post', redirect=post.get_absolute_url())
else:
msg = [field.errors for field in form if field.errors]
@@ -321,7 +307,7 @@ def ajax_comment_create(request):
if form.is_valid():
# Create the comment.
- post = Post.objects.create(type=Post.COMMENT, content=content, author=user, parent=parent)
+ post = form.save()
return ajax_success(msg='Created post', redirect=post.get_absolute_url())
else:
msg = [field.errors for field in form if field.errors]
@@ -470,6 +456,30 @@ def email_disable(request, uid):
return ajax_success(msg='Disabled messages')
+@ajax_error_wrapper(method="POST", login_required=True)
+def view_diff(request, uid):
+ """
+ View most recent diff to a post.
+ """
+
+ # View most recent diff made to a post
+ post = Post.objects.filter(uid=uid).first()
+
+ diffs = Diff.objects.filter(post=post).order_by('-pk')
+
+ # Post has no recorded changes,
+ if not diffs.exists():
+ return ajax_success(has_changes=False, msg='Post has no recorded changes')
+
+ # Change new line chars to break line tags.
+ context = dict(diffs=diffs)
+ tmpl = loader.get_template(template_name='diff.html')
+ tmpl = tmpl.render(context)
+
+ # Return newly created diff
+ return ajax_success(has_changes=True, msg='Showing changes', diff=tmpl)
+
+
def similar_posts(request, uid):
"""
Return a feed populated with posts similar to the one in the request.
diff --git a/biostar/forum/auth.py b/biostar/forum/auth.py
index c7d223869..e64824496 100644
--- a/biostar/forum/auth.py
+++ b/biostar/forum/auth.py
@@ -3,7 +3,8 @@
import re
import urllib.parse as urlparse
from datetime import timedelta
-
+from difflib import Differ, SequenceMatcher, HtmlDiff, unified_diff
+import bs4
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.core.cache import cache
@@ -21,7 +22,7 @@
from biostar.utils.helpers import get_ip
from . import util, awards
from .const import *
-from .models import Post, Vote, Subscription, Badge, delete_post_cache, Log, SharedLink
+from .models import Post, Vote, Subscription, Badge, delete_post_cache, Log, SharedLink, Diff
User = get_user_model()
@@ -67,7 +68,8 @@ def delete_cache(prefix, user):
import datetime
-ICONS = [ "monsterid", "robohash", "wavatar", "retro"]
+ICONS = ["monsterid", "robohash", "wavatar", "retro"]
+
def gravatar_url(email, style='mp', size=80, force=None):
global ICONS
@@ -208,18 +210,19 @@ def create_post_from_json(**json_data):
return
-def create_post(author, title, content, request, root=None, parent=None, ptype=Post.QUESTION, tag_val="", nodups=True):
+def create_post(author, title, content, request=None, root=None, parent=None, ptype=Post.QUESTION, tag_val="",
+ nodups=True):
# Check if a post with this exact content already exists.
post = Post.objects.filter(content=content, author=author).order_by('-creation_date').first()
# How many seconds since the last post should we disallow duplicates.
- time_frame = 60
- if nodups and post:
- # Check to see if this post was made within given timeframe
- delta_secs = (util.now() - post.creation_date).seconds
- if delta_secs < time_frame:
+ frame = 60
+ delta = (util.now() - post.creation_date).seconds if post else frame
+
+ if nodups and delta < frame:
+ if request:
messages.warning(request, "Post with this content was created recently.")
- return post
+ return post
post = Post.objects.create(title=title, content=content, root=root, parent=parent,
type=ptype, tag_val=tag_val, author=author)
@@ -228,6 +231,55 @@ def create_post(author, title, content, request, root=None, parent=None, ptype=P
return post
+def diff_ratio(text1, text2):
+
+ # Do not match on spaces
+ s = SequenceMatcher(lambda char: re.match(r'\w+', char), text1, text2)
+ return round(s.ratio(), 5)
+
+
+def compute_diff(text, post, user):
+ """
+ Compute and return Diff object for diff between text and post.content
+ """
+
+ # Skip on post creation
+ if not post:
+ return
+
+ ratio = diff_ratio(text1=text, text2=post.content)
+
+ # Skip no changes detected
+ if ratio == 1:
+ return
+
+ # Compute diff between text and post.
+ content = post.content.splitlines()
+ text = text.splitlines()
+
+ diff = unified_diff(content, text)
+ diff = [f"{line}\n" if not line.endswith('\n') else line for line in diff]
+ diff = ''.join(diff)
+
+ # See if a diff has been made by this user in the past 10 minutes
+ dobj = Diff.objects.filter(post=post, author=post.author).first()
+
+ # 10 minute time frame between
+ frame = 6 * 100
+ delta = (util.now() - dobj.created).seconds if dobj else frame
+
+ # Create diff object within time frame or the person editing is a mod.
+ if delta >= frame or user != post.author:
+ # Create diff object for this user.
+ dobj = Diff.objects.create(diff=diff, post=post, author=user)
+
+ # Only log when anyone but the author commits changes.
+ if user != post.author:
+ db_logger(user=user, action=Log.EDIT, text=f'edited post', target=post.author, post=post)
+
+ return dobj
+
+
def merge_profiles(main, alias):
"""
Merge alias profile into main
diff --git a/biostar/forum/forms.py b/biostar/forum/forms.py
index d9186ad31..35e598b0a 100644
--- a/biostar/forum/forms.py
+++ b/biostar/forum/forms.py
@@ -11,7 +11,7 @@
from snowpenguin.django.recaptcha2.widgets import ReCaptchaWidget
from biostar.accounts.models import User
from .models import Post, SharedLink
-from biostar.forum import models, auth
+from biostar.forum import models, auth, util
from .const import *
@@ -146,10 +146,16 @@ def edit(self):
if self.user != self.post.author and not self.user.profile.is_moderator:
raise forms.ValidationError("Only the author or a moderator can edit a post.")
data = self.cleaned_data
+
self.post.title = data.get('title')
- self.post.content = data.get("content")
+ content = data.get('content', self.post.content)
+ # Calculate diff and save to db
+ auth.compute_diff(text=content, post=self.post, user=self.user)
+ self.post.content = content
+
self.post.type = data.get('post_type')
self.post.tag_val = data.get('tag_val')
+ self.post.lastedit_date = util.now()
self.post.lastedit_user = self.user
self.post.save()
return self.post
@@ -187,13 +193,12 @@ def clean_content(self):
class PostShortForm(forms.Form):
MIN_LEN, MAX_LEN = 10, 10000
- parent_uid = forms.CharField(widget=forms.HiddenInput(), min_length=2, max_length=32, required=False)
- content = forms.CharField(widget=forms.Textarea,
- min_length=MIN_LEN, max_length=MAX_LEN, strip=False)
+ content = forms.CharField(widget=forms.Textarea, min_length=MIN_LEN, max_length=MAX_LEN, strip=False)
- def __init__(self, user=None, post=None, *args, **kwargs):
+ def __init__(self, post, user=None, request=None, ptype=Post.COMMENT, *args, **kwargs):
self.user = user
self.post = post
+ self.ptype = ptype
super().__init__(*args, **kwargs)
self.fields['content'].strip = False
@@ -207,9 +212,23 @@ def clean(self):
raise forms.ValidationError("You need to be logged in.")
return cleaned_data
+ def edit(self):
+ # Set the fields for this post.
+ self.post.lastedit_user = self.user
+ self.post.lastedit_date = util.now()
+ content = self.cleaned_data.get('content', self.post.content)
+ auth.compute_diff(text=content, post=self.post, user=self.user)
+ self.post.content = content
+ self.post.save()
+
+ def save(self):
+ content = self.cleaned_data.get('content', self.post.content)
+ post = auth.create_post(parent=self.post, author=self.user, content=content, ptype=self.ptype,
+ root=self.post.root, title=self.post.title)
+ return post
-class MergeProfiles(forms.Form):
+class MergeProfiles(forms.Form):
main = forms.CharField(label='Main user email', max_length=100, required=True)
alias = forms.CharField(label='Alias email to merge to main', max_length=100, required=True)
diff --git a/biostar/forum/herald.py b/biostar/forum/herald.py
index 4974b2b9c..bcafd7a83 100644
--- a/biostar/forum/herald.py
+++ b/biostar/forum/herald.py
@@ -100,7 +100,7 @@ def herald_publisher(request, limit=20, nmin=1):
# Reset status on published links.
# SharedLink.objects.filter(status=SharedLink.PUBLISHED).update(status=SharedLink.ACCEPTED)
- heralds = SharedLink.objects.filter(status=SharedLink.ACCEPTED).order_by('-creation_date')[:limit]
+ heralds = SharedLink.objects.filter(status=SharedLink.ACCEPTED).order_by('-pk')[:limit]
count = heralds.count()
if count < nmin:
@@ -177,6 +177,7 @@ def herald_list(request):
# Add the Link attribute.
link = form.cleaned_data['url']
text = form.cleaned_data['text']
+
# Create the herald_list objects.
herald = SharedLink.objects.create(author=user, text=text, url=link)
messages.success(request, 'Submitted for review.')
diff --git a/biostar/forum/management/commands/tasks.py b/biostar/forum/management/commands/tasks.py
index a1563edf0..5b4082b9b 100644
--- a/biostar/forum/management/commands/tasks.py
+++ b/biostar/forum/management/commands/tasks.py
@@ -6,7 +6,7 @@
from biostar import VERSION
from django.core.management.base import BaseCommand
from biostar.forum import models, util, tasks
-from biostar.forum.models import Post
+from biostar.forum.models import Post, Diff
from django.conf import settings
from biostar.accounts.models import User
from biostar.utils.decorators import timeit
@@ -17,8 +17,8 @@
BACKUP_DIR = os.path.join(settings.BASE_DIR, 'export', 'backup')
-BUMP, UNBUMP, AWARD = 'bump', 'unbump', 'award'
-CHOICES = [BUMP, UNBUMP, AWARD]
+CHOICES = ['bump', 'unbump', 'award']
+BUMP, UNBUMP, AWARD = CHOICES
def bump(uids, **kwargs):
@@ -83,8 +83,10 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument('--uids', '-u', type=str, required=False, default='', help='List of uids')
- parser.add_argument('--action', '-a', type=str, required=True, choices=CHOICES, default='',help='Action to take.')
- parser.add_argument('--limit', dest='limit', type=int, default=100, help='Limit how many users/posts to process.'),
+ parser.add_argument('--action', '-a', type=str, required=True, choices=CHOICES, default='',
+ help='Action to take.')
+ parser.add_argument('--limit', dest='limit', type=int, default=100,
+ help='Limit how many users/posts to process.'),
def handle(self, *args, **options):
action = options['action']
diff --git a/biostar/forum/migrations/0021_diff.py b/biostar/forum/migrations/0021_diff.py
new file mode 100644
index 000000000..ec3fbcac9
--- /dev/null
+++ b/biostar/forum/migrations/0021_diff.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.2 on 2021-07-11 19:58
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('forum', '0020_sharedlink_status'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Diff',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('diff', models.TextField(default='')),
+ ('created', models.DateTimeField(auto_now_add=True)),
+ ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='forum.post')),
+ ],
+ ),
+ ]
diff --git a/biostar/forum/models.py b/biostar/forum/models.py
index 5028b47ca..c2d4408fe 100644
--- a/biostar/forum/models.py
+++ b/biostar/forum/models.py
@@ -631,6 +631,34 @@ def save(self, *args, **kwargs):
def uid(self):
return self.pk
+
+class Diff(models.Model):
+
+ # Initial content state
+ diff = models.TextField(default='')
+
+ # Date this change was made.
+ created = models.DateTimeField(auto_now_add=True)
+
+ # Post this diff belongs to
+ post = models.ForeignKey(Post, on_delete=models.CASCADE)
+
+ # Person who created the diff
+ author = models.ForeignKey(User, on_delete=models.CASCADE)
+
+ def save(self, *args, **kwargs):
+
+ self.created = self.created or util.now()
+
+ super(Diff, self).save(*args, **kwargs)
+
+ @property
+ def breakline(self):
+ diff = self.diff
+ diff = diff.replace('\n', '
')
+ return diff
+
+
class Log(models.Model):
"""
Represents moderation actions
diff --git a/biostar/forum/signals.py b/biostar/forum/signals.py
index 1e2b5ffe8..81bf5e146 100644
--- a/biostar/forum/signals.py
+++ b/biostar/forum/signals.py
@@ -1,10 +1,10 @@
import logging
-from django.db.models.signals import post_save
+from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from taggit.models import Tag
from django.db.models import F, Q
from biostar.accounts.models import Profile, Message, User
-from biostar.forum.models import Post, Award, Subscription, SharedLink
+from biostar.forum.models import Post, Award, Subscription, SharedLink, Diff
from biostar.forum import tasks, auth, util
@@ -29,7 +29,7 @@ def send_award_message(sender, instance, created, **kwargs):
@receiver(post_save, sender=SharedLink)
-def send_award_message(sender, instance, created, **kwargs):
+def send_herald_message(sender, instance, created, **kwargs):
"""
Send message to users when they receive an award.
"""
@@ -39,6 +39,8 @@ def send_award_message(sender, instance, created, **kwargs):
# Let the user know we have received.
tasks.create_messages(template=template, extra_context=context, user_ids=[instance.author.pk])
+ logmsg = f"{instance.get_status_display().lower()} herald story {instance.url[:100]}"
+ auth.db_logger(user=instance.author, text=logmsg)
return
diff --git a/biostar/forum/static/forum.css b/biostar/forum/static/forum.css
index 247dc5586..e5f71abdf 100644
--- a/biostar/forum/static/forum.css
+++ b/biostar/forum/static/forum.css
@@ -116,6 +116,7 @@ blockquote.twitter-tweet {
background: white;
}
+
#topicbar {
border: none;
border-radius: 0;
diff --git a/biostar/forum/static/forum.js b/biostar/forum/static/forum.js
index 1b738c0c1..e4f6d482c 100644
--- a/biostar/forum/static/forum.js
+++ b/biostar/forum/static/forum.js
@@ -12,6 +12,38 @@ function captcha() {
}
}
+function view_diffs(uid, elem, post) {
+
+ if (elem.children().length > 0) {
+ elem.html('');
+ return
+ }
+
+ $.ajax("/view/diffs/" + uid + '/', {
+ type: 'POST',
+ dataType: 'json',
+ ContentType: 'application/json',
+
+ success: function (data) {
+ if (data.status === 'error') {
+ popup_message(post, data.msg, data.status);
+ return
+ }
+ if (data.has_changes) {
+ elem.html(data.diff)
+
+ } else {
+ popup_message(elem, data.msg, data.status);
+
+ }
+
+ },
+ error: function (xhr, status, text) {
+ error_message(post, xhr, status, text)
+ }
+ });
+
+}
function apply_vote(vote_elem) {
@@ -100,7 +132,7 @@ function moderate(uid, container, url) {
}
-function disable_emails(user_id, elem){
+function disable_emails(user_id, elem) {
var url = '/email/disable/{0}/'.format(user_id);
$.ajax(url, {
@@ -195,7 +227,6 @@ function activate_prism(elem) {
}
-
function herald_update(hpk, status, elem) {
$.ajax('/herald/update/' + hpk + '/',
@@ -261,7 +292,7 @@ $(document).ready(function () {
$('.ui.dropdown').dropdown();
- $('form .preview').each(function () {
+ $('#form .preview').each(function () {
var text = $(this).closest('form').find('.wmd-input').val();
var form = $(this).closest('form');
setTimeout(function () {
@@ -348,7 +379,10 @@ $(document).ready(function () {
on: 'hover',
content: 'Drag and Drop'
});
-
+ $(".view-diffs").popup({
+ on: 'hover',
+ content: 'View Changes'
+ });
$("[data-value='bookmark']").popup({
on: 'hover',
content: 'Bookmark '
@@ -359,7 +393,7 @@ $(document).ready(function () {
content: 'Accept'
});
- $("[data-value='decline']").popup({
+ $("[data-value='decline']").popup({
on: 'hover',
content: 'Decline'
});
@@ -419,7 +453,14 @@ $(document).ready(function () {
$(this).on('click', ".herald-sub", function (event) {
herald_subscribe($(this))
});
+ $(this).on('click', ".view-diffs", function (event) {
+ var post = $(this).closest('.post');
+ var uid = post.data('value');
+ var elem = post.find('.diff-cont').first();
+
+ view_diffs(uid, elem, post);
+ });
$('pre').addClass('language-bash');
$('code').addClass('language-bash');
Prism.highlightAll();
diff --git a/biostar/forum/static/inplace.js b/biostar/forum/static/inplace.js
index 9fe1aa13f..699a3c675 100644
--- a/biostar/forum/static/inplace.js
+++ b/biostar/forum/static/inplace.js
@@ -61,6 +61,7 @@ function cancel_inplace() {
//if (inplace.length){}
// Remove inplace item
inplace.remove();
+ $('.diff-cont').html('');
// Find the hidden items
var hidden = hidden_selector();
diff --git a/biostar/forum/templates/diff.html b/biostar/forum/templates/diff.html
new file mode 100644
index 000000000..8cc785ddc
--- /dev/null
+++ b/biostar/forum/templates/diff.html
@@ -0,0 +1,9 @@
+