diff --git a/app/blueprints/packages/packages.py b/app/blueprints/packages/packages.py index 06fe989..08489b1 100644 --- a/app/blueprints/packages/packages.py +++ b/app/blueprints/packages/packages.py @@ -137,7 +137,6 @@ def view(package): .all() releases = getReleases(package) - requests = [r for r in package.requests if r.status == 0] review_thread = package.review_thread if review_thread is not None and not review_thread.checkPerm(current_user, Permission.SEE_THREAD): @@ -171,7 +170,7 @@ def view(package): has_review = current_user.is_authenticated and PackageReview.query.filter_by(package=package, author=current_user).count() > 0 return render_template("packages/view.html", - package=package, releases=releases, requests=requests, + package=package, releases=releases, alternatives=alternatives, similar_topics=similar_topics, review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl, threads=threads.all(), has_review=has_review) diff --git a/app/blueprints/users/settings.py b/app/blueprints/users/settings.py index f52fc1c..52f95ae 100644 --- a/app/blueprints/users/settings.py +++ b/app/blueprints/users/settings.py @@ -1,11 +1,11 @@ from flask import * -from flask_login import current_user, login_required +from flask_login import current_user, login_required, logout_user from flask_wtf import FlaskForm from wtforms import * from wtforms.validators import * from app.models import * -from app.utils import nonEmptyOrNone, addAuditLog, randomString +from app.utils import nonEmptyOrNone, addAuditLog, randomString, rank_required from app.tasks.emails import send_verify_email from . import bp @@ -186,3 +186,38 @@ def email_notifications(username=None): return render_template("users/settings_email.html", form=form, user=user, types=types, is_new=is_new, tabs=get_setting_tabs(user), current_tab="notifications") + + +@bp.route("/users//delete/", methods=["GET", "POST"]) +@rank_required(UserRank.ADMIN) +def delete(username): + user: User = User.query.filter_by(username=username).first() + if not user: + abort(404) + + if request.method == "GET": + return render_template("users/delete.html", user=user, can_delete=user.can_delete()) + + if user.can_delete(): + msg = "Deleted user {}".format(user.username) + flash(msg, "success") + addAuditLog(AuditSeverity.MODERATION, current_user, msg, None) + + db.session.delete(user) + else: + user.replies.delete() + for thread in user.threads.all(): + db.session.delete(thread) + user.email = None + user.rank = UserRank.NOT_JOINED + + msg = "Deactivated user {}".format(user.username) + flash(msg, "success") + addAuditLog(AuditSeverity.MODERATION, current_user, msg, None) + + db.session.commit() + + if user == current_user: + logout_user() + + return redirect(url_for("homepage.home")) diff --git a/app/models.py b/app/models.py index eb5fb0d..0bd809a 100644 --- a/app/models.py +++ b/app/models.py @@ -166,16 +166,20 @@ class User(db.Model, UserMixin): donate_url = db.Column(db.String(255), nullable=True, default=None) # Content - notifications = db.relationship("Notification", primaryjoin="User.id==Notification.user_id", - order_by=desc(text("Notification.created_at"))) + notifications = db.relationship("Notification", foreign_keys="Notification.user_id", + order_by=desc(text("Notification.created_at")), back_populates="user", cascade="all, delete, delete-orphan") + caused_notifications = db.relationship("Notification", foreign_keys="Notification.causer_id", + back_populates="causer", cascade="all, delete, delete-orphan") + notification_preferences = db.relationship("UserNotificationPreferences", uselist=False, back_populates="user", + cascade="all, delete, delete-orphan") - notification_preferences = db.relationship("UserNotificationPreferences", uselist=False, back_populates="user") + audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.causer_id", back_populates="causer") packages = db.relationship("Package", back_populates="author", lazy="dynamic") - reviews = db.relationship("PackageReview", back_populates="author", order_by=db.desc("review_created_at")) - tokens = db.relationship("APIToken", back_populates="owner", lazy="dynamic") - threads = db.relationship("Thread", back_populates="author", lazy="dynamic") - replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic") + reviews = db.relationship("PackageReview", back_populates="author", order_by=db.desc("package_review_created_at"), cascade="all, delete, delete-orphan") + tokens = db.relationship("APIToken", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan") + threads = db.relationship("Thread", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan") + replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan") def __init__(self, username=None, active=False, email=None, password=None): self.username = username @@ -269,6 +273,9 @@ class User(db.Model, UserMixin): self.checkPerm(current_user, Permission.CHANGE_EMAIL) or \ self.checkPerm(current_user, Permission.CHANGE_RANK) + def can_delete(self): + return self.packages.count() == 0 and ForumTopic.query.filter_by(author=self).count() == 0 + class UserEmailVerification(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -367,10 +374,10 @@ class Notification(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) - user = db.relationship("User", foreign_keys=[user_id]) + user = db.relationship("User", foreign_keys=[user_id], back_populates="notifications") causer_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) - causer = db.relationship("User", foreign_keys=[causer_id]) + causer = db.relationship("User", foreign_keys=[causer_id], back_populates="caused_notifications") type = db.Column(db.Enum(NotificationType), nullable=False, default=NotificationType.OTHER) @@ -705,7 +712,7 @@ class Package(db.Model): downloads = db.Column(db.Integer, nullable=False, default=0) review_thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=True, default=None) - review_thread = db.relationship("Thread", foreign_keys=[review_thread_id]) + review_thread = db.relationship("Thread", foreign_keys=[review_thread_id], back_populates="is_review_thread") # Downloads repo = db.Column(db.String(200), nullable=True) @@ -734,7 +741,7 @@ class Package(db.Model): maintainers = db.relationship("User", secondary=maintainers, lazy="subquery") threads = db.relationship("Thread", back_populates="package", order_by=db.desc("thread_created_at"), foreign_keys="Thread.package_id") - reviews = db.relationship("PackageReview", back_populates="package", order_by=db.desc("review_created_at")) + reviews = db.relationship("PackageReview", back_populates="package", order_by=db.desc("package_review_created_at")) def __init__(self, package=None): if package is None: @@ -1337,10 +1344,12 @@ class Thread(db.Model): id = db.Column(db.Integer, primary_key=True) package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True) - package = db.relationship("Package", foreign_keys=[package_id]) + package = db.relationship("Package", foreign_keys=[package_id], back_populates="threads") + + is_review_thread = db.relationship("Package", foreign_keys=[Package.review_thread_id], back_populates="review_thread") review_id = db.Column(db.Integer, db.ForeignKey("package_review.id"), nullable=True) - review = db.relationship("PackageReview", foreign_keys=[review_id]) + review = db.relationship("PackageReview", foreign_keys=[review_id], cascade="all, delete") author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) author = db.relationship("User", back_populates="threads", foreign_keys=[author_id]) @@ -1438,7 +1447,7 @@ class PackageReview(db.Model): recommends = db.Column(db.Boolean, nullable=False) - thread = db.relationship("Thread", uselist=False, back_populates="review") + thread = db.relationship("Thread", uselist=False, back_populates="review", cascade="all, delete") def asSign(self): return 1 if self.recommends else -1 @@ -1473,14 +1482,13 @@ class AuditSeverity(enum.Enum): return item if type(item) == AuditSeverity else AuditSeverity[item] - class AuditLogEntry(db.Model): id = db.Column(db.Integer, primary_key=True) created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) - causer_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) - causer = db.relationship("User", foreign_keys=[causer_id]) + causer_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + causer = db.relationship("User", back_populates="", foreign_keys=[causer_id]) severity = db.Column(db.Enum(AuditSeverity), nullable=False) diff --git a/app/templates/admin/audit.html b/app/templates/admin/audit.html index 483dc01..278b98a 100644 --- a/app/templates/admin/audit.html +++ b/app/templates/admin/audit.html @@ -32,12 +32,16 @@ Audit Log
- + {% if entry.causer %} + - {{ entry.causer.display_name }} + {{ entry.causer.display_name }} + {% else %} + Deleted User + {% endif %}
diff --git a/app/templates/admin/audit_view.html b/app/templates/admin/audit_view.html index 72e0f27..8955781 100644 --- a/app/templates/admin/audit_view.html +++ b/app/templates/admin/audit_view.html @@ -10,9 +10,16 @@ {% endif %}

{{ entry.title }}

-

- {{ _("Caused by %(author)s.", author=entry.causer.display_name) }} -

+ + {% if entry.causer %} +

+ {{ _("Caused by %(author)s.", author=entry.causer.display_name) }} +

+ {% else %} +

+ {{ _("Caused by a deleted user.", author=entry.causer.display_name) }} +

+ {% endif %}
{{ entry.description }}
diff --git a/app/templates/users/delete.html b/app/templates/users/delete.html new file mode 100644 index 0000000..706621b --- /dev/null +++ b/app/templates/users/delete.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% block title %} + Delete user {{ user.display_name }} +{% endblock %} + +{% block content %} +
+ + +

{{ self.title() }}

+
+

Deleting is permanent

+ + {% if can_delete %} +

+ {{ _("This will delete your account, removing %(threads)d threads and %(replies)d replies.", + threads=user.threads.count(), replies=user.replies.count()) }} +

+ {% else %} +

+ {{ _("As you have packages and/or forum threads, your account can be fully deleted.") }} + {{ _("Instead, your account will be deactivated and all personal information wiped - including %(threads)d threads and %(replies)d replies.", + threads=user.threads.count(), replies=user.replies.count()) }} +

+ {% endif %} + + Cancel + +
+
+{% endblock %} diff --git a/migrations/versions/43dc7dbf64c8_.py b/migrations/versions/43dc7dbf64c8_.py new file mode 100644 index 0000000..864bd65 --- /dev/null +++ b/migrations/versions/43dc7dbf64c8_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 43dc7dbf64c8 +Revises: c1ea65e2b492 +Create Date: 2020-12-09 19:06:11.891807 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '43dc7dbf64c8' +down_revision = 'c1ea65e2b492' +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column('audit_log_entry', 'causer_id', + existing_type=sa.INTEGER(), + nullable=True) + + +def downgrade(): + op.alter_column('audit_log_entry', 'causer_id', + existing_type=sa.INTEGER(), + nullable=False) diff --git a/utils/downgrade_migration.sh b/utils/downgrade_migration.sh new file mode 100755 index 0000000..e94e162 --- /dev/null +++ b/utils/downgrade_migration.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +# Create a database migration, and copy it back to the host. + +docker exec contentdb_app_1 sh -c "FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db downgrade"