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 @@ +
+ {% for dobj in diffs %} + {{ dobj.breakline|safe }} +
+ changes made by {{ dobj.author.profile.name }} + {{ dobj.created|timesince }} ago +
+ {% endfor %} +
diff --git a/biostar/forum/templates/forms/form_inplace.html b/biostar/forum/templates/forms/form_inplace.html index 0d2c00c7e..8d0ecdd0e 100644 --- a/biostar/forum/templates/forms/form_inplace.html +++ b/biostar/forum/templates/forms/form_inplace.html @@ -6,7 +6,7 @@
-
+ {# Create a new comment #} {% if new %} {% endif %} -