Skip to content

Commit 6f13f5a

Browse files
authored
User admin feature (#824)
* User admin feature CSS should probably be extracted to mltshp-patterns, as long as we can segment it into a separate admin CSS bundle for admin-specific components. Inlined scripts here are also admin-specific, so could be placed in an admin.js file, if there are things that can be shared with other admin views. Presently, there isn't a need. * Use background task for cascade deletion * Isolate admin styles
1 parent 84e41fc commit 6f13f5a

File tree

20 files changed

+633
-95
lines changed

20 files changed

+633
-95
lines changed

.dockerignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ celeryconfig.py
1010
.eggs/
1111
build/
1212
html/
13+
web/
14+
worker/
1315
pip-log.txt
1416
.DS_Store
1517
*.swp

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: init start stop custom-build build shell test destroy migrate mysql
1+
.PHONY: init start stop custom-build build staging shell test destroy migrate mysql
22

33
init:
44
cp settings.example.py settings.py
@@ -18,6 +18,9 @@ custom-build:
1818
build:
1919
docker build -t mltshp/mltshp-web:latest .
2020

21+
staging:
22+
docker build --platform linux/amd64 -t mltshp/mltshp-web:staging .
23+
2124
shell:
2225
docker compose exec mltshp bash
2326

celeryconfig.example.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
## Celery configuration
88

99
# List of modules to import when celery starts.
10-
imports = ("tasks.timeline", "tasks.counts", "tasks.migration", "tasks.transcode")
10+
imports = ("tasks.timeline", "tasks.counts", "tasks.migration", "tasks.transcode", "tasks.admin")
1111

1212
task_routes = {
1313
"tasks.transcode.*": { "queue": "transcode" },

handlers/admin.py

Lines changed: 93 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66

77
from .base import BaseHandler
88
from models import Sharedfile, User, Shake, Invitation, Waitlist, ShakeCategory, \
9-
DmcaTakedown, Comment
10-
from lib.utilities import send_slack_notification
9+
DmcaTakedown, Comment, Favorite, PaymentLog, Conversation
10+
from lib.utilities import send_slack_notification, pretty_date
11+
from tasks.admin import delete_account
1112

1213

1314
class AdminBaseHandler(BaseHandler):
@@ -52,6 +53,62 @@ def get(self):
5253
return self.render("admin/index.html")
5354

5455

56+
class UserHandler(AdminBaseHandler):
57+
@tornado.web.authenticated
58+
def get(self, user_name):
59+
plan_names = {
60+
"mltshp-single-canceled": "Single Scoop - Canceled",
61+
"mltshp-single": "Single Scoop",
62+
"mltshp-double-canceled": "Double Scoop - Canceled",
63+
"mltshp-double": "Double Scoop",
64+
}
65+
user = User.get('name=%s', user_name)
66+
if not user:
67+
return self.redirect('/admin?error=User%20not%20found')
68+
69+
post_count = "{:,d}".format(Sharedfile.where_count("user_id=%s and deleted=0", user.id))
70+
shake_count = "{:,d}".format(Shake.where_count("user_id=%s and deleted=0", user.id))
71+
comment_count = "{:,d}".format(Comment.where_count("user_id=%s and deleted=0", user.id))
72+
like_count = "{:,d}".format(Favorite.where_count("user_id=%s and deleted=0", user.id))
73+
last_activity_date = user.get_last_activity_date()
74+
pretty_last_activity_date = last_activity_date and pretty_date(last_activity_date) or "None"
75+
subscribed = bool(user.is_paid)
76+
subscription = subscribed and user.active_paid_subscription()
77+
subscription_level = plan_names.get(user.stripe_plan_id) or None
78+
subscription_start = subscription and subscription['start_date']
79+
subscription_end = subscription and subscription['end_date']
80+
all_payments = PaymentLog.where("user_id=%s", user.id)
81+
total_payments = 0.00
82+
for payment in all_payments:
83+
if payment.status == "payment":
84+
total_payments = total_payments + float(payment.transaction_amount.split(" ")[1])
85+
uploaded_all_time_mb = "{:,.2f}".format(user.uploaded_kilobytes() / 1024)
86+
uploaded_this_month_mb = "{:,.2f}".format(user.uploaded_this_month() / 1024)
87+
# select all _original_ posts from this user; we care less about reposts for this view
88+
recent_posts = Sharedfile.where("user_id=%s and original_id=0 order by created_at desc limit 50", user.id)
89+
recent_comments = Comment.where("user_id=%s order by created_at desc limit 100", user.id)
90+
91+
return self.render(
92+
"admin/user.html",
93+
user=user,
94+
user_name=user_name,
95+
post_count=post_count,
96+
shake_count=shake_count,
97+
comment_count=comment_count,
98+
like_count=like_count,
99+
uploaded_all_time_mb=uploaded_all_time_mb,
100+
uploaded_this_month_mb=uploaded_this_month_mb,
101+
total_payments=total_payments,
102+
subscribed=subscribed,
103+
subscription_level=subscription_level,
104+
subscription_start=subscription_start,
105+
subscription_end=subscription_end,
106+
last_activity_date=last_activity_date,
107+
pretty_last_activity_date=pretty_last_activity_date,
108+
recent_posts=recent_posts,
109+
recent_comments=recent_comments,)
110+
111+
55112
class NSFWUserHandler(AdminBaseHandler):
56113
@tornado.web.authenticated
57114
def get(self):
@@ -80,11 +137,17 @@ def get(self):
80137
if not self.admin_user.is_superuser():
81138
return self.redirect('/admin')
82139

140+
share_key = self.get_argument("share_key", None)
141+
if share_key:
142+
sharedfile = Sharedfile.get("share_key=%s AND deleted=0", share_key)
143+
else:
144+
sharedfile = None
145+
83146
return self.render(
84147
"admin/image-takedown.html",
85-
share_key="",
148+
share_key=share_key or "",
86149
confirm_step=False,
87-
sharedfile=None,
150+
sharedfile=sharedfile,
88151
comment="",
89152
canceled=self.get_argument('canceled', "0") == "1",
90153
deleted=self.get_argument('deleted', "0") == "1")
@@ -168,19 +231,30 @@ def post(self):
168231

169232

170233
class DeleteUserHandler(AdminBaseHandler):
171-
@tornado.web.authenticated
172-
def get(self):
173-
if not self.admin_user.is_superuser():
174-
return self.redirect('/admin')
175-
return self.render('admin/delete-user.html')
176-
177234
@tornado.web.authenticated
178235
def post(self):
179-
user_id = self.get_argument('user_id')
236+
# Only a superuser can delete users
237+
if not self.admin_user.is_superuser():
238+
return self.write({'error': 'not allowed'})
239+
180240
user_name = self.get_argument('user_name')
181-
user = User.get('name=%s and id=%s', user_name, user_id)
182-
user.delete()
183-
return self.redirect('/user/%s' % user_name)
241+
user = None
242+
if user_name:
243+
user = User.get('name=%s', user_name)
244+
245+
if user:
246+
# admin users cannot be deleted (moderator or superuser)
247+
if user.is_admin():
248+
return self.write({'error': 'cannot delete admin'})
249+
250+
# Flag as deleted; send full deletion work to the background
251+
user.deleted = 1
252+
user.save()
253+
254+
delete_account.delay_or_run(user_id=user.id)
255+
return self.write({'response': 'ok' })
256+
else:
257+
return self.write({'error': 'user not found'})
184258

185259

186260
class FlagNSFWHandler(AdminBaseHandler):
@@ -190,6 +264,7 @@ def post(self, user_name):
190264
if not user:
191265
return self.redirect('/')
192266

267+
json = int(self.get_argument("json", 0))
193268
nsfw = int(self.get_argument("nsfw", 0))
194269
if nsfw == 1:
195270
user.flag_nsfw()
@@ -198,7 +273,10 @@ def post(self, user_name):
198273
user.save()
199274
send_slack_notification("%s flagged user '%s' as %s" % (self.admin_user.name, user.name, nsfw == 1 and "NSFW" or "SFW"),
200275
channel="#moderation", icon_emoji=":ghost:", username="modbot")
201-
return self.redirect("/user/%s" % user.name)
276+
if json == 1:
277+
return self.write({'response': 'ok' })
278+
else:
279+
return self.redirect("/user/%s" % user.name)
202280

203281

204282
class RecommendedGroupShakeHandler(AdminBaseHandler):

handlers/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def render_string(self, template_name, **kwargs):
8181
kwargs['current_user_object'] = current_user_object
8282
kwargs['site_is_readonly'] = options.readonly == 1
8383
kwargs['disable_signups'] = options.disable_signups == 1
84+
kwargs['xsrf_token'] = self.xsrf_token
8485
# site merchandise promotions are shown to members
8586
kwargs['show_promos'] = options.show_promos and (
8687
current_user_object and current_user_object.is_paid == 1)

lib/uimodules.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,10 @@ def render(self, sharedfile, current_user=None, list_view=False, show_attributio
163163

164164

165165
class ImageMedium(UIModule):
166-
def render(self, sharedfile):
166+
def render(self, sharedfile, direct=False):
167167
sharedfile_user = sharedfile.user()
168168
return self.render_string("uimodules/image-medium.html", sharedfile=sharedfile, \
169-
sharedfile_user=sharedfile_user)
169+
sharedfile_user=sharedfile_user, direct=direct)
170170

171171
class ShakeFollow(UIModule):
172172
def render(self, follow_user=None, follow_shake=None, current_user=None,

models/sharedfile.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ def feed_date(self):
604604
"""
605605
return rfc822_date(self.created_at)
606606

607-
def thumbnail_url(self):
607+
def thumbnail_url(self, direct=False):
608608
# If we are running on Fastly, then we can use the Image Optimizer to
609609
# resize a given image. Thumbnail size is 100x100. This size is used
610610
# for the conversations page.
@@ -619,14 +619,17 @@ def thumbnail_url(self):
619619
size = self.size
620620
# Fastly I/O won't process images > 50mb, so condition for that
621621
if sourcefile.type == 'image' and options.use_fastly and size > 0 and size < 50_000_000:
622-
return f"https://{options.cdn_host}/r/{self.share_key}?width=100"
622+
if direct:
623+
return f"https://{options.cdn_host}/s3/originals/{sourcefile.file_key}?width=100"
624+
else:
625+
return f"https://{options.cdn_host}/r/{self.share_key}?width=100"
623626
else:
624627
return s3_url(options.aws_key, options.aws_secret, options.aws_bucket, \
625628
file_path="thumbnails/%s" % (sourcefile.thumb_key), seconds=3600)
626629

627-
def small_thumbnail_url(self):
630+
def small_thumbnail_url(self, direct=False):
628631
# If we are running on Fastly, then we can use the Image Optimizer to
629-
# resize a given image. Small thumbnails are 240x184 at most. This size is
632+
# resize a given image. Small thumbnails are 270-wide at most. This size is
630633
# currently only used within the admin UI.
631634
sourcefile = self.sourcefile()
632635
size = 0
@@ -638,7 +641,10 @@ def small_thumbnail_url(self):
638641
size = self.size
639642
# Fastly I/O won't process images > 50mb, so condition for that
640643
if sourcefile.type == 'image' and options.use_fastly and size > 0 and size < 50_000_000:
641-
return f"https://{options.cdn_host}/r/{self.share_key}?width=240&height=184&fit=bounds"
644+
if direct:
645+
return f"https://{options.cdn_host}/s3/originals/{sourcefile.file_key}?width=270"
646+
else:
647+
return f"https://{options.cdn_host}/r/{self.share_key}?width=270"
642648
else:
643649
return s3_url(options.aws_key, options.aws_secret, options.aws_bucket, \
644650
file_path="smalls/%s" % (sourcefile.small_key), seconds=3600)

models/sourcefile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def get_from_file(file_path, sha1_value, type='image', skip_s3=None, content_typ
136136
small = img.copy()
137137

138138
thumb.thumbnail((100,100), Image.Resampling.LANCZOS)
139-
small.thumbnail((240,184), Image.Resampling.LANCZOS)
139+
small.thumbnail((270,200), Image.Resampling.LANCZOS)
140140

141141
thumb.save(thumb_cstr, format="JPEG")
142142
small.save(small_cstr, format="JPEG")

models/user.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import postmark
1212
from tornado.options import define, options
1313

14-
from lib.utilities import email_re, transform_to_square_thumbnail, utcnow
14+
from lib.utilities import email_re, transform_to_square_thumbnail, utcnow, pretty_date
1515
from lib.flyingcow import Model, Property
1616
from lib.flyingcow.cache import ModelQueryCache
1717
from lib.flyingcow.db import IntegrityError
@@ -505,10 +505,11 @@ def delete(self):
505505
service.save()
506506

507507
user_shake = self.shake()
508-
subscriptions = subscription.Subscription.where("user_id=%s or shake_id=%s", self.id, user_shake.id)
509-
for sub in subscriptions:
510-
sub.deleted = 1
511-
sub.save()
508+
if user_shake:
509+
subscriptions = subscription.Subscription.where("user_id=%s or shake_id=%s", self.id, user_shake.id)
510+
for sub in subscriptions:
511+
sub.deleted = 1
512+
sub.save()
512513

513514
shakemanagers = shakemanager.ShakeManager.where("user_id=%s and deleted=0", self.id)
514515
for sm in shakemanagers:
@@ -725,6 +726,25 @@ def update_email(self, email):
725726
self.email = email
726727
self._validate_email_uniqueness()
727728

729+
def get_last_activity_date(self):
730+
sql = """SELECT max(created_at) last_activity_date FROM (
731+
SELECT max(created_at) created_at FROM sharedfile WHERE user_id=%s AND deleted=0
732+
UNION
733+
SELECT max(created_at) created_at FROM comment WHERE user_id=%s AND deleted=0
734+
UNION
735+
SELECT max(created_at) created_at FROM favorite WHERE user_id=%s AND deleted=0
736+
UNION
737+
SELECT max(created_at) created_at FROM bookmark WHERE user_id=%s
738+
) as activities
739+
"""
740+
response = self.query(sql, self.id, self.id, self.id, self.id)
741+
if response and response[0]['last_activity_date']:
742+
return response[0]['last_activity_date']
743+
return None
744+
745+
def pretty_created_at(self):
746+
return pretty_date(self.created_at)
747+
728748
def uploaded_kilobytes(self, start_time=None, end_time=None):
729749
"""
730750
Returns the total number of kilobytes uploaded for the time period specified
@@ -750,6 +770,13 @@ def uploaded_kilobytes(self, start_time=None, end_time=None):
750770
def can_post(self):
751771
return self.can_upload_this_month()
752772

773+
def uploaded_this_month(self):
774+
month_days = calendar.monthrange(utcnow().year,utcnow().month)
775+
start_time = utcnow().strftime("%Y-%m-01")
776+
end_time = utcnow().strftime("%Y-%m-" + str(month_days[1]) )
777+
778+
return self.uploaded_kilobytes(start_time=start_time, end_time=end_time)
779+
753780
def can_upload_this_month(self):
754781
"""
755782
Returns if this user can upload this month.
@@ -761,11 +788,7 @@ def can_upload_this_month(self):
761788
if self.is_plus():
762789
return True
763790

764-
month_days = calendar.monthrange(utcnow().year,utcnow().month)
765-
start_time = utcnow().strftime("%Y-%m-01")
766-
end_time = utcnow().strftime("%Y-%m-" + str(month_days[1]) )
767-
768-
total_bytes = self.uploaded_kilobytes(start_time=start_time, end_time=end_time)
791+
total_bytes = self.uploaded_this_month()
769792

770793
if total_bytes == 0:
771794
return True

routes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@
218218
(r"/admin/interesting-stats", handlers.admin.InterestingStatsHandler),
219219
(r"/admin/waitlist", handlers.admin.WaitlistHandler),
220220
(r"/admin/nsfw-users", handlers.admin.NSFWUserHandler),
221+
(r"/admin/user/([a-zA-Z0-9_\-]+)", handlers.admin.UserHandler),
221222
(r"/admin/user/([a-zA-Z0-9_\-]+)/flag-nsfw",
222223
handlers.admin.FlagNSFWHandler),
223224
(r"/admin/delete-user", handlers.admin.DeleteUserHandler),

0 commit comments

Comments
 (0)