diff --git a/WSGI.py b/WSGI.py old mode 100755 new mode 100644 diff --git a/migrations/README b/migrations/README old mode 100755 new mode 100644 diff --git a/migrations/env.py b/migrations/env.py old mode 100755 new mode 100644 diff --git a/migrations/script.py.mako b/migrations/script.py.mako old mode 100755 new mode 100644 diff --git a/migrations/versions/7f064e009cab_add_report_table.py b/migrations/versions/7f064e009cab_add_report_table.py new file mode 100644 index 0000000..3e3992e --- /dev/null +++ b/migrations/versions/7f064e009cab_add_report_table.py @@ -0,0 +1,56 @@ +"""Add Report table + +Revision ID: 7f064e009cab +Revises: 2bceb2cb4d7c +Create Date: 2017-05-29 16:50:28.720980 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7f064e009cab' +down_revision = '2bceb2cb4d7c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('nyaa_reports', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_time', sa.DateTime(), nullable=True), + sa.Column('reason', sa.String(length=255), nullable=False), + + # sqlalchemy_utils.types.choice.ChoiceType() + sa.Column('status', sa.Integer(), nullable=False), + + sa.Column('torrent_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['torrent_id'], ['nyaa_torrents.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('sukebei_reports', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_time', sa.DateTime(), nullable=True), + sa.Column('reason', sa.String(length=255), nullable=False), + + # sqlalchemy_utils.types.choice.ChoiceType() + sa.Column('status', sa.Integer(), nullable=False), + + sa.Column('torrent_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['torrent_id'], ['sukebei_torrents.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('sukebei_reports') + op.drop_table('nyaa_reports') + # ### end Alembic commands ### diff --git a/nyaa/forms.py b/nyaa/forms.py index 36cf714..ba0ad60 100644 --- a/nyaa/forms.py +++ b/nyaa/forms.py @@ -7,7 +7,8 @@ import os import re from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileRequired -from wtforms import StringField, PasswordField, BooleanField, TextAreaField, SelectField +from wtforms import StringField, PasswordField, BooleanField, TextAreaField, SelectField,\ + HiddenField from wtforms.validators import DataRequired, Optional, Email, Length, EqualTo, ValidationError from wtforms.validators import Regexp @@ -295,6 +296,21 @@ class TorrentFileData(object): # https://wiki.theory.org/BitTorrentSpecification#Metainfo_File_Structure +class ReportForm(FlaskForm): + reason = TextAreaField('Report reason', [ + Length(min=3, max=255, + message='Report reason must be at least %(min)d characters long ' + 'and %(max)d at most.'), + DataRequired('You must provide a valid report reason.') + ]) + + +class ReportActionForm(FlaskForm): + action = SelectField(choices=[('close', 'Close'), ('hide', 'Hide'), ('delete', 'Delete')]) + torrent = HiddenField() + report = HiddenField() + + def _validate_trackers(torrent_dict, tracker_to_check_for=None): announce = torrent_dict.get('announce') announce_string = _validate_bytes(announce, 'announce', test_decode='utf-8') diff --git a/nyaa/models.py b/nyaa/models.py old mode 100755 new mode 100644 index 9b20c42..22d349f --- a/nyaa/models.py +++ b/nyaa/models.py @@ -580,6 +580,65 @@ class User(db.Model): return self.level >= UserLevelType.TRUSTED +class ReportStatus(IntEnum): + IN_REVIEW = 0 + VALID = 1 + INVALID = 2 + + +class ReportBase(DeclarativeHelperBase): + __tablename_base__ = 'reports' + + id = db.Column(db.Integer, primary_key=True) + created_time = db.Column(db.DateTime(timezone=False), default=datetime.utcnow) + reason = db.Column(db.String(length=255), nullable=False) + status = db.Column(ChoiceType(ReportStatus, impl=db.Integer()), nullable=False) + + @declarative.declared_attr + def torrent_id(cls): + return db.Column(db.Integer, db.ForeignKey( + cls._table_prefix('torrents.id'), ondelete='CASCADE'), nullable=False) + + @declarative.declared_attr + def user_id(cls): + return db.Column(db.Integer, db.ForeignKey('users.id')) + + @declarative.declared_attr + def user(cls): + return db.relationship('User', uselist=False, lazy="joined") + + @declarative.declared_attr + def torrent(cls): + return db.relationship(cls._flavor_prefix('Torrent'), uselist=False, lazy="joined") + + def __init__(self, torrent_id, user_id, reason): + self.torrent_id = torrent_id + self.user_id = user_id + self.reason = reason + self.status = ReportStatus.IN_REVIEW + + 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() + + @classmethod + def by_id(cls, id): + return cls.query.get(id) + + @classmethod + def not_reviewed(cls, page): + reports = cls.query.filter_by(status=0).paginate(page=page, per_page=20) + return reports + + @classmethod + def remove_reviewed(cls, id): + return cls.query.filter(cls.torrent_id == id, cls.status == 0).delete() + + # Actually declare our site-specific classes # Torrent @@ -672,6 +731,15 @@ class SukebeiComment(CommentBase, db.Model): __flavor__ = 'Sukebei' +# Report +class NyaaReport(ReportBase, db.Model): + __flavor__ = 'Nyaa' + + +class SukebeiReport(ReportBase, db.Model): + __flavor__ = 'Sukebei' + + # Choose our defaults for models.Torrent etc if app.config['SITE_FLAVOR'] == 'nyaa': Torrent = NyaaTorrent @@ -682,6 +750,7 @@ if app.config['SITE_FLAVOR'] == 'nyaa': MainCategory = NyaaMainCategory SubCategory = NyaaSubCategory Comment = NyaaComment + Report = NyaaReport TorrentNameSearch = NyaaTorrentNameSearch elif app.config['SITE_FLAVOR'] == 'sukebei': @@ -693,5 +762,6 @@ elif app.config['SITE_FLAVOR'] == 'sukebei': MainCategory = SukebeiMainCategory SubCategory = SukebeiSubCategory Comment = SukebeiComment + Report = SukebeiReport TorrentNameSearch = SukebeiTorrentNameSearch diff --git a/nyaa/routes.py b/nyaa/routes.py index c4eb33d..eaf1e59 100644 --- a/nyaa/routes.py +++ b/nyaa/routes.py @@ -29,7 +29,6 @@ from email.utils import formatdate from flask_paginate import Pagination - DEBUG_API = False DEFAULT_MAX_SEARCH_RESULT = 1000 DEFAULT_PER_PAGE = 75 @@ -675,11 +674,13 @@ def view_torrent(torrent_id): if torrent.filelist: files = json.loads(torrent.filelist.filelist_blob.decode('utf-8')) + report_form = forms.ReportForm() return flask.render_template('view.html', torrent=torrent, files=files, comment_form=comment_form, comments=torrent.comments, - can_edit=can_edit) + can_edit=can_edit, + report_form=report_form) @app.route('/view//comment//delete', methods=['POST']) @@ -798,6 +799,66 @@ def download_torrent(torrent_id): return resp +@app.route('/view//submit_report', methods=['POST']) +def submit_report(torrent_id): + if not flask.g.user: + flask.abort(403) + + form = forms.ReportForm(flask.request.form) + + if flask.request.method == 'POST' and form.validate(): + report_reason = form.reason.data + current_user_id = flask.g.user.id + report = models.Report( + torrent_id=torrent_id, + user_id=current_user_id, + reason=report_reason) + + db.session.add(report) + db.session.commit() + flask.flash('Successfully reported torrent!', 'success') + + return flask.redirect(flask.url_for('view_torrent', torrent_id=torrent_id)) + + +@app.route('/reports', methods=['GET', 'POST']) +def view_reports(): + if not flask.g.user or not flask.g.user.is_moderator: + flask.abort(403) + + page = flask.request.args.get('p', flask.request.args.get('offset', 1, int), int) + reports = models.Report.not_reviewed(page) + report_action = forms.ReportActionForm(flask.request.form) + + if flask.request.method == 'POST' and report_action.validate(): + action = report_action.action.data + torrent_id = report_action.torrent.data + report_id = report_action.report.data + torrent = models.Torrent.by_id(torrent_id) + report = models.Report.by_id(report_id) + + if not torrent or not report or report.status != 0: + flask.abort(404) + else: + if action == 'delete': + torrent.deleted = True + report.status = 1 + elif action == 'hide': + torrent.hidden = True + report.status = 1 + else: + report.status = 2 + + models.Report.remove_reviewed(torrent_id) + db.session.commit() + flask.flash('Closed report #{}'.format(report.id), 'success') + return flask.redirect(flask.url_for('view_reports')) + + return flask.render_template('reports.html', + reports=reports, + report_action=report_action) + + def _get_cached_torrent_file(torrent): # Note: obviously temporary cached_torrent = os.path.join(app.config['BASE_DIR'], diff --git a/nyaa/static/css/main.css b/nyaa/static/css/main.css old mode 100755 new mode 100644 diff --git a/nyaa/templates/edit.html b/nyaa/templates/edit.html old mode 100755 new mode 100644 diff --git a/nyaa/templates/layout.html b/nyaa/templates/layout.html old mode 100755 new mode 100644 index 91bb818..ffbb18f --- a/nyaa/templates/layout.html +++ b/nyaa/templates/layout.html @@ -88,6 +88,9 @@ {% elif config.SITE_FLAVOR == 'sukebei' %}
  • Fun
  • {% endif %} + {% if g.user.is_moderator %} +
  • Reports
  • + {% endif %}