Merge pull request #165 from nyaadevs/comments

Comments
This commit is contained in:
A nyaa developer 2017-05-23 00:13:35 +02:00 committed by GitHub
commit c35f136133
9 changed files with 281 additions and 24 deletions

View File

@ -0,0 +1,48 @@
"""Add comments table.
Revision ID: d0eeb8049623
Revises: 3001f79b7722
Create Date: 2017-05-22 22:58:12.039149
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd0eeb8049623'
down_revision = '3001f79b7722'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('nyaa_comments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('torrent_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('created_time', sa.DateTime(), nullable=True),
sa.Column('text', sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(['torrent_id'], ['nyaa_torrents.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('sukebei_comments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('torrent_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('created_time', sa.DateTime(), nullable=True),
sa.Column('text', sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(['torrent_id'], ['sukebei_torrents.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('nyaa_comments')
op.drop_table('sukebei_comments')
# ### end Alembic commands ###

View File

@ -126,11 +126,18 @@ class DisabledSelectField(SelectField):
raise ValueError(self.gettext('Not a valid choice')) raise ValueError(self.gettext('Not a valid choice'))
class CommentForm(FlaskForm):
comment = TextAreaField('Make a comment', [
Length(min=3, max=255, message='Comment must be at least %(min)d characters '
'long and %(max)d at most.'),
DataRequired()
])
class EditForm(FlaskForm): class EditForm(FlaskForm):
display_name = StringField('Torrent display name', [ display_name = StringField('Torrent display name', [
Length(min=3, max=255, Length(min=3, max=255, message='Torrent display name must be at least %(min)d characters '
message='Torrent display name must be at least %(min)d characters long ' 'long and %(max)d at most.')
'and %(max)d at most.')
]) ])
category = DisabledSelectField('Category') category = DisabledSelectField('Category')

View File

@ -1,3 +1,4 @@
import flask
from enum import Enum, IntEnum from enum import Enum, IntEnum
from datetime import datetime, timezone from datetime import datetime, timezone
from nyaa import app, db from nyaa import app, db
@ -11,7 +12,8 @@ from ipaddress import ip_address
import re import re
import base64 import base64
from markupsafe import escape as escape_markup from markupsafe import escape as escape_markup
from urllib.parse import unquote as unquote_url from urllib.parse import urlencode, unquote as unquote_url
from hashlib import md5
if app.config['USE_MYSQL']: if app.config['USE_MYSQL']:
from sqlalchemy.dialects import mysql from sqlalchemy.dialects import mysql
@ -99,6 +101,8 @@ class Torrent(db.Model):
cascade="all, delete-orphan", back_populates='torrent', lazy='joined') cascade="all, delete-orphan", back_populates='torrent', lazy='joined')
trackers = db.relationship('TorrentTrackers', uselist=True, trackers = db.relationship('TorrentTrackers', uselist=True,
cascade="all, delete-orphan", lazy='joined') cascade="all, delete-orphan", lazy='joined')
comments = db.relationship('Comment', uselist=True,
cascade="all, delete-orphan")
def __repr__(self): def __repr__(self):
return '<{0} #{1.id} \'{1.display_name}\' {1.filesize}b>'.format(type(self).__name__, self) return '<{0} #{1.id} \'{1.display_name}\' {1.filesize}b>'.format(type(self).__name__, self)
@ -317,6 +321,27 @@ class SubCategory(db.Model):
return cls.query.get((sub_cat_id, main_cat_id)) return cls.query.get((sub_cat_id, main_cat_id))
class Comment(db.Model):
__tablename__ = DB_TABLE_PREFIX + 'comments'
id = db.Column(db.Integer, primary_key=True)
torrent_id = db.Column(db.Integer, db.ForeignKey(
DB_TABLE_PREFIX + 'torrents.id', ondelete='CASCADE'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'))
created_time = db.Column(db.DateTime(timezone=False), default=datetime.utcnow)
text = db.Column(db.String(length=255), nullable=False)
user = db.relationship('User', uselist=False, back_populates='comments', lazy="joined")
def __repr__(self):
return '<Comment %r>' % self.id
@property
def created_utc_timestamp(self):
''' Returns a UTC POSIX timestamp, as seconds '''
return (self.created_time - UTC_EPOCH).total_seconds()
class UserLevelType(IntEnum): class UserLevelType(IntEnum):
REGULAR = 0 REGULAR = 0
TRUSTED = 1 TRUSTED = 1
@ -346,7 +371,8 @@ class User(db.Model):
last_login_date = db.Column(db.DateTime(timezone=False), default=None, nullable=True) last_login_date = db.Column(db.DateTime(timezone=False), default=None, nullable=True)
last_login_ip = db.Column(db.Binary(length=16), default=None, nullable=True) last_login_ip = db.Column(db.Binary(length=16), default=None, nullable=True)
torrents = db.relationship('Torrent', back_populates='user', lazy="dynamic") torrents = db.relationship('Torrent', back_populates='user', lazy='dynamic')
comments = db.relationship('Comment', back_populates='user', lazy='dynamic')
# session = db.relationship('Session', uselist=False, back_populates='user') # session = db.relationship('Session', uselist=False, back_populates='user')
def __init__(self, username, email, password): def __init__(self, username, email, password):
@ -369,6 +395,25 @@ class User(db.Model):
] ]
return all(checks) return all(checks)
def gravatar_url(self):
# from http://en.gravatar.com/site/implement/images/python/
size = 120
# construct the url
default_avatar = flask.url_for('static', filename='img/avatar/default.png', _external=True)
gravatar_url = 'https://www.gravatar.com/avatar/{}?{}'.format(
md5(self.email.encode('utf-8').lower()).hexdigest(),
urlencode({'d': default_avatar, 's': str(size)}))
return gravatar_url
@property
def userlevel_str(self):
if self.level == UserLevelType.REGULAR:
return 'User'
elif self.level == UserLevelType.TRUSTED:
return 'Trusted'
elif self.level >= UserLevelType.MODERATOR:
return 'Moderator'
@property @property
def ip_string(self): def ip_string(self):
if self.last_login_ip: if self.last_login_ip:

View File

@ -7,6 +7,7 @@ from nyaa import torrents
from nyaa import backend from nyaa import backend
from nyaa import api_handler from nyaa import api_handler
from nyaa.search import search_elastic, search_db from nyaa.search import search_elastic, search_db
from sqlalchemy.orm import joinedload
import config import config
import json import json
@ -570,21 +571,48 @@ def upload():
return flask.render_template('upload.html', upload_form=upload_form), status_code return flask.render_template('upload.html', upload_form=upload_form), status_code
@app.route('/view/<int:torrent_id>') @app.route('/view/<int:torrent_id>', methods=['GET', 'POST'])
def view_torrent(torrent_id): def view_torrent(torrent_id):
torrent = models.Torrent.by_id(torrent_id) if flask.request.method == 'POST':
torrent = models.Torrent.by_id(torrent_id)
viewer = flask.g.user else:
torrent = models.Torrent.query \
.options(joinedload('filelist'),
joinedload('comments')) \
.filter_by(id=torrent_id) \
.first()
if not torrent: if not torrent:
flask.abort(404) flask.abort(404)
# Only allow admins see deleted torrents # Only allow admins see deleted torrents
if torrent.deleted and not (viewer and viewer.is_moderator): if torrent.deleted and not (flask.g.user and flask.g.user.is_moderator):
flask.abort(404) flask.abort(404)
comment_form = None
if flask.g.user:
comment_form = forms.CommentForm()
if flask.request.method == 'POST':
if not flask.g.user:
flask.abort(403)
if comment_form.validate():
comment_text = (comment_form.comment.data or '').strip()
comment = models.Comment(
torrent_id=torrent_id,
user_id=flask.g.user.id,
text=comment_text)
db.session.add(comment)
db.session.commit()
flask.flash('Comment successfully posted.', 'success')
return flask.redirect(flask.url_for('view_torrent', torrent_id=torrent_id))
# Only allow owners and admins to edit torrents # Only allow owners and admins to edit torrents
can_edit = viewer and (viewer is torrent.user or viewer.is_moderator) can_edit = flask.g.user and (flask.g.user is torrent.user or flask.g.user.is_moderator)
files = None files = None
if torrent.filelist: if torrent.filelist:
@ -592,10 +620,31 @@ def view_torrent(torrent_id):
return flask.render_template('view.html', torrent=torrent, return flask.render_template('view.html', torrent=torrent,
files=files, files=files,
viewer=viewer, comment_form=comment_form,
comments=torrent.comments,
can_edit=can_edit) can_edit=can_edit)
@app.route('/view/<int:torrent_id>/comment/<int:comment_id>/delete', methods=['POST'])
def delete_comment(torrent_id, comment_id):
if not flask.g.user:
flask.abort(403)
comment = models.Comment.query.filter_by(id=comment_id).first()
if not comment:
flask.abort(404)
if not (comment.user.id == flask.g.user.id or flask.g.user.is_moderator):
flask.abort(403)
db.session.delete(comment)
db.session.commit()
flask.flash('Comment successfully deleted.', 'success')
return flask.redirect(flask.url_for('view_torrent', torrent_id=torrent_id))
@app.route('/view/<int:torrent_id>/edit', methods=['GET', 'POST']) @app.route('/view/<int:torrent_id>/edit', methods=['GET', 'POST'])
def edit_torrent(torrent_id): def edit_torrent(torrent_id):
torrent = models.Torrent.by_id(torrent_id) torrent = models.Torrent.by_id(torrent_id)
@ -608,11 +657,11 @@ def edit_torrent(torrent_id):
flask.abort(404) flask.abort(404)
# Only allow admins edit deleted torrents # Only allow admins edit deleted torrents
if torrent.deleted and not (editor and editor.is_moderator): if torrent.deleted and not (flask.g.user and flask.g.user.is_moderator):
flask.abort(404) flask.abort(404)
# Only allow torrent owners or admins edit torrents # Only allow torrent owners or admins edit torrents
if not editor or not (editor is torrent.user or editor.is_moderator): if not flask.g.user or not (flask.g.user is torrent.user or flask.g.user.is_moderator):
flask.abort(403) flask.abort(403)
if flask.request.method == 'POST' and form.validate(): if flask.request.method == 'POST' and form.validate():
@ -628,9 +677,9 @@ def edit_torrent(torrent_id):
torrent.complete = form.is_complete.data torrent.complete = form.is_complete.data
torrent.anonymous = form.is_anonymous.data torrent.anonymous = form.is_anonymous.data
if editor.is_trusted: if flask.g.user.is_trusted:
torrent.trusted = form.is_trusted.data torrent.trusted = form.is_trusted.data
if editor.is_moderator: if flask.g.user.is_moderator:
torrent.deleted = form.is_deleted.data torrent.deleted = form.is_deleted.data
db.session.commit() db.session.commit()
@ -658,8 +707,7 @@ def edit_torrent(torrent_id):
return flask.render_template('edit.html', return flask.render_template('edit.html',
form=form, form=form,
torrent=torrent, torrent=torrent)
editor=editor)
@app.route('/view/<int:torrent_id>/magnet') @app.route('/view/<int:torrent_id>/magnet')
@ -752,7 +800,37 @@ def _create_user_class_choices(user):
return default, choices return default, choices
@app.template_filter()
def timesince(dt, default='just now'):
"""
Returns string representing "time since" e.g.
3 minutes ago, 5 hours ago etc.
Date and time (UTC) are returned if older than 1 day.
"""
now = datetime.utcnow()
diff = now - dt
periods = (
(diff.days, 'day', 'days'),
(diff.seconds / 3600, 'hour', 'hours'),
(diff.seconds / 60, 'minute', 'minutes'),
(diff.seconds, 'second', 'seconds'),
)
if diff.days >= 1:
return dt.strftime('%Y-%m-%d %H:%M UTC')
else:
for period, singular, plural in periods:
if period >= 1:
return '%d %s ago' % (period, singular if period == 1 else plural)
return default
# #################################### STATIC PAGES #################################### # #################################### STATIC PAGES ####################################
@app.route('/rules', methods=['GET']) @app.route('/rules', methods=['GET'])
def site_rules(): def site_rules():
return flask.render_template('rules.html') return flask.render_template('rules.html')

View File

@ -218,3 +218,26 @@ table.torrent-list tbody tr td a:visited {
ul.nav-tabs#profileTabs { ul.nav-tabs#profileTabs {
margin-bottom: 15px; margin-bottom: 15px;
} }
.comments-panel {
width: 99%;
margin: 0 auto;
margin-top:10px;
margin-bottom:10px;
}
.comment-box {
width: 95%;
margin: 0 auto;
margin-top:30px;
margin-bottom:10px;
}
.delete-comment-form {
position: relative;
float: right;
}
.avatar {
max-width: 120px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -7,7 +7,7 @@
{% set torrent_url = url_for('view_torrent', torrent_id=torrent.id) %} {% set torrent_url = url_for('view_torrent', torrent_id=torrent.id) %}
<h1> <h1>
Edit Torrent <a href="{{ torrent_url }}">#{{torrent.id}}</a> Edit Torrent <a href="{{ torrent_url }}">#{{torrent.id}}</a>
{% if (torrent.user != None) and (torrent.user != editor) %} {% if (torrent.user != None) and (torrent.user != g.user) %}
(by <a href="{{ url_for('view_user', user_name=torrent.user.username) }}">{{ torrent.user.username }}</a>) (by <a href="{{ url_for('view_user', user_name=torrent.user.username) }}">{{ torrent.user.username }}</a>)
{% endif %} {% endif %}
</h1> </h1>
@ -31,7 +31,7 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="control-label">Torrent flags</label> <label class="control-label">Torrent flags</label>
<div> <div>
{% if editor.is_moderator %} {% if g.user.is_moderator %}
<label class="btn btn-primary"> <label class="btn btn-primary">
{{ form.is_deleted }} {{ form.is_deleted }}
Deleted Deleted
@ -58,7 +58,7 @@
Anonymous Anonymous
</label> </label>
{% endif %} {% endif %}
{% if editor.is_trusted %} {% if g.user.is_trusted %}
<label class="btn btn-success" title="Mark torrent trusted"> <label class="btn btn-success" title="Mark torrent trusted">
{{ form.is_trusted }} {{ form.is_trusted }}
Trusted Trusted

View File

@ -1,6 +1,7 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}{{ torrent.display_name }} :: {{ config.SITE_NAME }}{% endblock %} {% block title %}{{ torrent.display_name }} :: {{ config.SITE_NAME }}{% endblock %}
{% block body %} {% block body %}
{% from "_formhelpers.html" import render_field %}
<div class="panel panel-{% if torrent.deleted %}deleted{% elif torrent.remake %}danger{% elif torrent.trusted %}success{% else %}default{% endif %}"> <div class="panel panel-{% if torrent.deleted %}deleted{% elif torrent.remake %}danger{% elif torrent.trusted %}success{% else %}default{% endif %}">
<div class="panel-heading"{% if torrent.hidden %} style="background-color: darkgray;"{% endif %}> <div class="panel-heading"{% if torrent.hidden %} style="background-color: darkgray;"{% endif %}>
<h3 class="panel-title"> <h3 class="panel-title">
@ -28,9 +29,9 @@
{%- if not torrent.anonymous and torrent.user -%} {%- if not torrent.anonymous and torrent.user -%}
<a href="{{ user_url }}">{{ torrent.user.username }}</a> <a href="{{ user_url }}">{{ torrent.user.username }}</a>
{%- else -%} {%- else -%}
Anonymous {% if torrent.user and (viewer == torrent.user or viewer.is_moderator) %}(<a href="{{ user_url }}">{{ torrent.user.username }}</a>){% endif %} Anonymous {% if torrent.user and (g.user == torrent.user or g.user.is_moderator) %}(<a href="{{ user_url }}">{{ torrent.user.username }}</a>){% endif %}
{%- endif -%} {%- endif -%}
{%- if viewer and viewer.is_superadmin and torrent.uploader_ip -%} {%- if g.user and g.user.is_superadmin and torrent.uploader_ip -%}
({{ torrent.uploader_ip_string }}) ({{ torrent.uploader_ip_string }})
{%- endif -%} {%- endif -%}
</div> </div>
@ -129,6 +130,60 @@
</div> </div>
{% endif %} {% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
Comments - {{ comments|length }}
</h3>
</div>
{% for comment in comments %}
<div class="panel panel-default comments-panel">
<div class="panel-body">
<div class="col-md-2">
<p>
{% set user_url = torrent.user and url_for('view_user', user_name=comment.user.username) %}
<a href="{{ user_url }}">{{ comment.user.username }}</a>
{% if comment.user.id == torrent.uploader_id and not torrent.anonymous %}
(uploader)
{% endif %}
</p>
<p>{{ comment.user.userlevel_str }}</p>
<p><img class="avatar" src="{{ comment.user.gravatar_url() }}"></p>
</div>
<div class="col-md-10">
<div class="row">
<small>{{ comment.created_time | timesince }}</small>
{% if g.user.is_moderator or g.user.id == comment.user_id %}
<form class="delete-comment-form" action="{{ url_for('delete_comment', torrent_id=torrent.id, comment_id=comment.id) }}" method="POST">
<button name="submit" type="submit" class="btn btn-danger btn-sm" title="Delete">Delete</button>
</form>
{% endif %}
</div>
<div class="row">
{# Escape newlines into html entities because CF strips blank newlines #}
<div id="torrent-comment{{ comment.id }}">{{ comment.text }}</div>
</div>
</div>
</div>
</div>
<script>
var target = document.getElementById('torrent-comment{{ comment.id }}');
var text = target.innerHTML;
var reader = new commonmark.Parser({safe: true});
var writer = new commonmark.HtmlRenderer({safe: true, softbreak: '<br />'});
var parsed = reader.parse(text.trim());
target.innerHTML = writer.render(parsed);
</script>
{% endfor %}
{% if comment_form %}
<form class="comment-box" method="POST">
{{ comment_form.csrf_token }}
{{ render_field(comment_form.comment, class_='form-control') }}
<input type="submit" value="Submit" class="btn btn-success btn-sm">
</form>
{% endif %}
</div>
<script> <script>
var target = document.getElementById('torrent-description'); var target = document.getElementById('torrent-description');
var text = target.innerHTML; var text = target.innerHTML;

View File

@ -14,6 +14,7 @@ USED_TRACKERS = OrderedSet()
# Limit the amount of trackers added into .torrent files # Limit the amount of trackers added into .torrent files
MAX_TRACKERS = 5 MAX_TRACKERS = 5
def read_trackers_from_file(file_object): def read_trackers_from_file(file_object):
USED_TRACKERS.clear() USED_TRACKERS.clear()