Skip to content

Commit 345d160

Browse files
committed
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.
1 parent 84e41fc commit 345d160

File tree

16 files changed

+577
-79
lines changed

16 files changed

+577
-79
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

handlers/admin.py

Lines changed: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
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
1111

1212

1313
class AdminBaseHandler(BaseHandler):
@@ -52,6 +52,62 @@ def get(self):
5252
return self.render("admin/index.html")
5353

5454

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

139+
share_key = self.get_argument("share_key", None)
140+
if share_key:
141+
sharedfile = Sharedfile.get("share_key=%s AND deleted=0", share_key)
142+
else:
143+
sharedfile = None
144+
83145
return self.render(
84146
"admin/image-takedown.html",
85-
share_key="",
147+
share_key=share_key or "",
86148
confirm_step=False,
87-
sharedfile=None,
149+
sharedfile=sharedfile,
88150
comment="",
89151
canceled=self.get_argument('canceled', "0") == "1",
90152
deleted=self.get_argument('deleted', "0") == "1")
@@ -168,19 +230,26 @@ def post(self):
168230

169231

170232
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-
177233
@tornado.web.authenticated
178234
def post(self):
179-
user_id = self.get_argument('user_id')
235+
# Only a superuser can delete users
236+
if not self.admin_user.is_superuser():
237+
return self.write({'error': 'not allowed'})
238+
180239
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)
240+
user = None
241+
if user_name:
242+
user = User.get('name=%s', user_name)
243+
244+
if user:
245+
# admin users cannot be deleted (moderator or superuser)
246+
if user.is_admin():
247+
return self.write({'error': 'cannot delete admin'})
248+
249+
user.delete()
250+
return self.write({'response': 'ok' })
251+
else:
252+
return self.write({'error': 'user not found'})
184253

185254

186255
class FlagNSFWHandler(AdminBaseHandler):
@@ -190,6 +259,7 @@ def post(self, user_name):
190259
if not user:
191260
return self.redirect('/')
192261

262+
json = int(self.get_argument("json", 0))
193263
nsfw = int(self.get_argument("nsfw", 0))
194264
if nsfw == 1:
195265
user.flag_nsfw()
@@ -198,7 +268,10 @@ def post(self, user_name):
198268
user.save()
199269
send_slack_notification("%s flagged user '%s' as %s" % (self.admin_user.name, user.name, nsfw == 1 and "NSFW" or "SFW"),
200270
channel="#moderation", icon_emoji=":ghost:", username="modbot")
201-
return self.redirect("/user/%s" % user.name)
271+
if json == 1:
272+
return self.write({'response': 'ok' })
273+
else:
274+
return self.redirect("/user/%s" % user.name)
202275

203276

204277
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),

tasks/counts.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,19 @@ def calculate_saves(sharedfile_id, **kwargs):
4646
"""
4747
db = Connection(options.database_host, options.database_name, options.database_user, options.database_password)
4848
sharedfile = db.get("select original_id from sharedfile where id = %s", sharedfile_id)
49-
original_id = sharedfile['original_id']
50-
51-
# If this file is original, calculate it's save count by all sharedfile where this file is the original_id.
52-
if original_id == 0:
53-
original_saves = db.get("SELECT count(id) AS count FROM sharedfile where original_id = %s and deleted = 0", sharedfile_id)
54-
db.execute("UPDATE sharedfile set save_count = %s WHERE id = %s", original_saves['count'], sharedfile_id)
55-
# Otherwise, we need to update the original's save count and this file's save count.
56-
else:
57-
original_saves = db.get("SELECT count(id) AS count FROM sharedfile where original_id = %s and deleted = 0", original_id)
58-
db.execute("UPDATE sharedfile set save_count = %s WHERE id = %s", original_saves['count'], original_id)
49+
if sharedfile:
50+
original_id = sharedfile['original_id']
5951

60-
# Calc this files new save count, only based on parent since its not original.
61-
parent_saves = db.get("SELECT count(id) AS count FROM sharedfile where parent_id = %s and deleted = 0", sharedfile_id)
62-
db.execute("UPDATE sharedfile set save_count = %s WHERE id = %s", parent_saves['count'], sharedfile_id)
52+
# If this file is original, calculate it's save count by all sharedfile where this file is the original_id.
53+
if original_id == 0:
54+
original_saves = db.get("SELECT count(id) AS count FROM sharedfile where original_id = %s and deleted = 0", sharedfile_id)
55+
db.execute("UPDATE sharedfile set save_count = %s WHERE id = %s", original_saves['count'], sharedfile_id)
56+
# Otherwise, we need to update the original's save count and this file's save count.
57+
else:
58+
original_saves = db.get("SELECT count(id) AS count FROM sharedfile where original_id = %s and deleted = 0", original_id)
59+
db.execute("UPDATE sharedfile set save_count = %s WHERE id = %s", original_saves['count'], original_id)
60+
61+
# Calc this files new save count, only based on parent since its not original.
62+
parent_saves = db.get("SELECT count(id) AS count FROM sharedfile where parent_id = %s and deleted = 0", sharedfile_id)
63+
db.execute("UPDATE sharedfile set save_count = %s WHERE id = %s", parent_saves['count'], sharedfile_id)
6364
db.close()

0 commit comments

Comments
 (0)