diff --git a/migrations/versions/d0eeb8049623_add_comments.py b/migrations/versions/d0eeb8049623_add_comments.py new file mode 100644 index 0000000..4b8599a --- /dev/null +++ b/migrations/versions/d0eeb8049623_add_comments.py @@ -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 ### diff --git a/nyaa/forms.py b/nyaa/forms.py index 1a61706..be79f51 100644 --- a/nyaa/forms.py +++ b/nyaa/forms.py @@ -126,11 +126,18 @@ class DisabledSelectField(SelectField): 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): display_name = StringField('Torrent display name', [ - Length(min=3, max=255, - message='Torrent display name must be at least %(min)d characters long ' - 'and %(max)d at most.') + Length(min=3, max=255, message='Torrent display name must be at least %(min)d characters ' + 'long and %(max)d at most.') ]) category = DisabledSelectField('Category') diff --git a/nyaa/models.py b/nyaa/models.py index 07f75da..e623aa8 100644 --- a/nyaa/models.py +++ b/nyaa/models.py @@ -1,3 +1,4 @@ +import flask from enum import Enum, IntEnum from datetime import datetime, timezone from nyaa import app, db @@ -11,7 +12,8 @@ from ipaddress import ip_address import re import base64 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']: from sqlalchemy.dialects import mysql @@ -99,6 +101,8 @@ class Torrent(db.Model): cascade="all, delete-orphan", back_populates='torrent', lazy='joined') trackers = db.relationship('TorrentTrackers', uselist=True, cascade="all, delete-orphan", lazy='joined') + comments = db.relationship('Comment', uselist=True, + cascade="all, delete-orphan") def __repr__(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)) +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 '' % 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): REGULAR = 0 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_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') def __init__(self, username, email, password): @@ -369,6 +395,25 @@ class User(db.Model): ] 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 def ip_string(self): if self.last_login_ip: diff --git a/nyaa/routes.py b/nyaa/routes.py index cc2c508..0168b2a 100644 --- a/nyaa/routes.py +++ b/nyaa/routes.py @@ -7,6 +7,7 @@ from nyaa import torrents from nyaa import backend from nyaa import api_handler from nyaa.search import search_elastic, search_db +from sqlalchemy.orm import joinedload import config import json @@ -570,21 +571,48 @@ def upload(): return flask.render_template('upload.html', upload_form=upload_form), status_code -@app.route('/view/') +@app.route('/view/', methods=['GET', 'POST']) def view_torrent(torrent_id): - torrent = models.Torrent.by_id(torrent_id) - - viewer = flask.g.user - + if flask.request.method == 'POST': + torrent = models.Torrent.by_id(torrent_id) + else: + torrent = models.Torrent.query \ + .options(joinedload('filelist'), + joinedload('comments')) \ + .filter_by(id=torrent_id) \ + .first() if not torrent: flask.abort(404) # 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) + 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 - 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 if torrent.filelist: @@ -592,10 +620,31 @@ def view_torrent(torrent_id): return flask.render_template('view.html', torrent=torrent, files=files, - viewer=viewer, + comment_form=comment_form, + comments=torrent.comments, can_edit=can_edit) +@app.route('/view//comment//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//edit', methods=['GET', 'POST']) def edit_torrent(torrent_id): torrent = models.Torrent.by_id(torrent_id) @@ -608,11 +657,11 @@ def edit_torrent(torrent_id): flask.abort(404) # 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) # 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) if flask.request.method == 'POST' and form.validate(): @@ -628,9 +677,9 @@ def edit_torrent(torrent_id): torrent.complete = form.is_complete.data torrent.anonymous = form.is_anonymous.data - if editor.is_trusted: + if flask.g.user.is_trusted: torrent.trusted = form.is_trusted.data - if editor.is_moderator: + if flask.g.user.is_moderator: torrent.deleted = form.is_deleted.data db.session.commit() @@ -658,8 +707,7 @@ def edit_torrent(torrent_id): return flask.render_template('edit.html', form=form, - torrent=torrent, - editor=editor) + torrent=torrent) @app.route('/view//magnet') @@ -752,7 +800,37 @@ def _create_user_class_choices(user): 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 #################################### + + @app.route('/rules', methods=['GET']) def site_rules(): return flask.render_template('rules.html') diff --git a/nyaa/static/css/main.css b/nyaa/static/css/main.css index 07e5a6a..fdca7db 100644 --- a/nyaa/static/css/main.css +++ b/nyaa/static/css/main.css @@ -218,3 +218,26 @@ table.torrent-list tbody tr td a:visited { ul.nav-tabs#profileTabs { 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; +} diff --git a/nyaa/static/img/avatar/default.png b/nyaa/static/img/avatar/default.png new file mode 100644 index 0000000..7cde0a6 Binary files /dev/null and b/nyaa/static/img/avatar/default.png differ diff --git a/nyaa/templates/edit.html b/nyaa/templates/edit.html index b65ce0c..e608f8d 100644 --- a/nyaa/templates/edit.html +++ b/nyaa/templates/edit.html @@ -7,7 +7,7 @@ {% set torrent_url = url_for('view_torrent', torrent_id=torrent.id) %}

Edit Torrent #{{torrent.id}} - {% if (torrent.user != None) and (torrent.user != editor) %} + {% if (torrent.user != None) and (torrent.user != g.user) %} (by {{ torrent.user.username }}) {% endif %}

@@ -31,7 +31,7 @@
- {% if editor.is_moderator %} + {% if g.user.is_moderator %} {% endif %} - {% if editor.is_trusted %} + {% if g.user.is_trusted %}