Add user deletion / deactivation

This commit is contained in:
rubenwardy 2020-12-09 19:33:31 +00:00
parent fd0b203f1e
commit 778a602aa6
8 changed files with 147 additions and 29 deletions

View File

@ -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)

View File

@ -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/<username>/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"))

View File

@ -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)

View File

@ -32,12 +32,16 @@ Audit Log
</div>
<div class="col-sm-2 text-muted">
<img
class="img-fluid user-photo img-thumbnail img-thumbnail-1"
style="max-height: 22px;"
src="{{ entry.causer.getProfilePicURL() }}" />
{% if entry.causer %}
<img
class="img-fluid user-photo img-thumbnail img-thumbnail-1"
style="max-height: 22px;"
src="{{ entry.causer.getProfilePicURL() }}" />
<span class="pl-2">{{ entry.causer.display_name }}</span>
<span class="pl-2">{{ entry.causer.display_name }}</span>
{% else %}
<i>Deleted User</i>
{% endif %}
</div>
<div class="col-sm">

View File

@ -10,9 +10,16 @@
{% endif %}
<h1>{{ entry.title }}</h1>
<p class="text-muted mb-4">
{{ _("Caused by %(author)s.", author=entry.causer.display_name) }}
</p>
{% if entry.causer %}
<p class="text-muted mb-4">
{{ _("Caused by %(author)s.", author=entry.causer.display_name) }}
</p>
{% else %}
<p class="text-muted mb-4">
{{ _("Caused by a deleted user.", author=entry.causer.display_name) }}
</p>
{% endif %}
<pre><code>{{ entry.description }}</code></pre>

View File

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}
Delete user {{ user.display_name }}
{% endblock %}
{% block content %}
<form method="POST" action="" class="card box_grey">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<h3 class="card-header">{{ self.title() }}</h3>
<div class="card-body">
<p><b>Deleting is permanent</b></p>
{% if can_delete %}
<p>
{{ _("This will delete your account, removing %(threads)d threads and %(replies)d replies.",
threads=user.threads.count(), replies=user.replies.count()) }}
</p>
{% else %}
<p>
{{ _("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()) }}
</p>
{% endif %}
<a class="btn btn-secondary mr-3" href="{{ url_for('users.profile_edit', username=user.username) }}">Cancel</a>
<input type="submit" value="{% if can_delete %}Delete{% else %}Deactivate{% endif %}" class="btn btn-danger" />
</div>
</form>
{% endblock %}

View File

@ -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)

5
utils/downgrade_migration.sh Executable file
View File

@ -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"